Normalize semantic layer descriptions

This commit is contained in:
Luca Martial 2026-05-11 00:31:15 -07:00
parent c82989119b
commit 86c818a454
21 changed files with 498 additions and 37 deletions

View file

@ -22,7 +22,7 @@ export interface KtxCliPackageInfo {
}
export interface KtxCliIo {
stdout: { isTTY?: boolean; write(chunk: string): void };
stdout: { isTTY?: boolean; columns?: number; write(chunk: string): void };
stderr: { write(chunk: string): void };
}

View file

@ -3,6 +3,7 @@ import { describe, expect, it, vi } from 'vitest';
import type { KtxPublicIngestProject, KtxPublicIngestTargetResult } from './public-ingest.js';
import {
extractProgressMessage,
createRepainter,
initViewState,
parseIngestSummary,
parseScanSummary,
@ -11,13 +12,14 @@ import {
viewStateFromSourceProgress,
} from './context-build-view.js';
function makeIo(options: { isTTY?: boolean } = {}) {
function makeIo(options: { isTTY?: boolean; columns?: number } = {}) {
let stdout = '';
let stderr = '';
return {
io: {
stdout: {
isTTY: options.isTTY,
columns: options.columns,
write: (chunk: string) => {
stdout += chunk;
},
@ -305,6 +307,31 @@ describe('renderContextBuildView', () => {
});
});
describe('createRepainter', () => {
it('moves up visual rows, not just newline count, when content wraps', () => {
const io = makeIo({ isTTY: true, columns: 5 });
const repainter = createRepainter(io.io);
repainter.paint('abcdefghijk\n');
repainter.paint('updated\n');
repainter.paint('done\n');
const cursorMoves = [...io.stdout().matchAll(/\u001b\[(\d+)A\r/g)].map((match) => Number(match[1]));
expect(cursorMoves).toEqual([3, 2]);
});
it('returns to the start of a single-line frame without moving up when content has no newline', () => {
const io = makeIo({ isTTY: true, columns: 80 });
const repainter = createRepainter(io.io);
repainter.paint('hello');
repainter.paint('bye');
expect(io.stdout()).toContain('\rbye');
expect(io.stdout()).not.toContain('\u001b[1A\rbye');
});
});
describe('runContextBuild', () => {
it('executes scan targets before source-ingest targets', async () => {
const io = makeIo();

View file

@ -226,6 +226,7 @@ export function renderContextBuildView(
// --- IO Capture ---
const ESC_K_RE = new RegExp(`${ESC.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\[K`, 'g');
const ANSI_RE = /\x1b\[[0-9;]*m/g;
export function extractProgressMessage(chunk: string): string | null {
const cleaned = chunk.replace(/^\r/, '').replace(ESC_K_RE, '').replace(/\n$/, '').trim();
@ -342,16 +343,45 @@ export function viewStateFromSourceProgress(
// --- Repaint ---
export function createRepainter(io: KtxCliIo) {
let lastLineCount = 0;
let hasPainted = false;
let lastCursorUpRows = 0;
const terminalColumns = () => {
for (const columns of [io.stdout.columns, process.stdout.columns]) {
if (typeof columns === 'number' && Number.isFinite(columns) && columns > 0) return columns;
}
return 80;
};
const visualRows = (line: string, columns: number) => {
const plainLength = line.replace(ANSI_RE, '').length;
return Math.max(1, Math.ceil(plainLength / columns));
};
const cursorUpRowsAfterWrite = (content: string) => {
const columns = terminalColumns();
const endsWithNewline = content.endsWith('\n');
const lines = content.split('\n');
return lines.reduce((sum, line, index) => {
if (index === lines.length - 1) {
return endsWithNewline ? sum : sum + Math.max(0, visualRows(line, columns) - 1);
}
return sum + visualRows(line, columns);
}, 0);
};
return {
paint(content: string) {
if (lastLineCount > 0) {
io.stdout.write(`${ESC}[${lastLineCount}A\r`);
if (hasPainted) {
if (lastCursorUpRows > 0) {
io.stdout.write(`${ESC}[${lastCursorUpRows}A`);
}
io.stdout.write('\r');
}
io.stdout.write(content.replaceAll('\n', `${ESC}[K\n`));
io.stdout.write(`${ESC}[J`);
lastLineCount = (content.match(/\n/g) ?? []).length;
hasPainted = true;
lastCursorUpRows = cursorUpRowsAfterWrite(content);
},
};
}