@alisio/alisio-code 0.1.0-alpha.25 → 0.1.0-alpha.27
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/analysis.d.ts +27 -0
- package/dist/analysis.js +145 -0
- package/dist/banner.js +2 -1
- package/dist/main.js +63 -2
- package/dist/tui/app.js +203 -5
- package/dist/tui/artifact-preview.d.ts +52 -0
- package/dist/tui/artifact-preview.js +212 -0
- package/dist/tui/artifacts.d.ts +54 -0
- package/dist/tui/artifacts.js +207 -0
- package/dist/tui/components.d.ts +5 -0
- package/dist/tui/components.js +35 -3
- package/dist/tui/open-path.d.ts +38 -0
- package/dist/tui/open-path.js +80 -0
- package/dist/tui/settings.d.ts +11 -0
- package/dist/tui/settings.js +51 -0
- package/dist/tui/state.d.ts +20 -0
- package/dist/tui/state.js +97 -0
- package/package.json +4 -4
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Terminal preview of text-like artifacts (spec §16.4), shown in place of the `/artifacts` picker:
|
|
3
|
+
* Markdown (first 256 KiB), CSV/TSV as a table (200 rows × 20 columns, with a legend), JSON
|
|
4
|
+
* pretty-printed (256 KiB) and text/code with highlighting (2 000 lines). Dashboards, PDFs,
|
|
5
|
+
* images and office files never render here: they only open with the system app.
|
|
6
|
+
*/
|
|
7
|
+
import { open } from "node:fs/promises";
|
|
8
|
+
import { CsvParser, decodeTextBytes, detectDelimiter } from "@alisio/core";
|
|
9
|
+
import { Key, matchesKey, truncateToWidth } from "@earendil-works/pi-tui";
|
|
10
|
+
import { terminalCapabilities } from "../banner.js";
|
|
11
|
+
import { renderUiBlock } from "./components.js";
|
|
12
|
+
import { style } from "./theme.js";
|
|
13
|
+
export const PREVIEW_MAX_BYTES = 256 * 1024;
|
|
14
|
+
export const PREVIEW_MAX_LINES = 2000;
|
|
15
|
+
export const PREVIEW_MAX_ROWS = 200;
|
|
16
|
+
export const PREVIEW_MAX_COLUMNS = 20;
|
|
17
|
+
/** CSV bytes read to count rows for the legend (beyond: "more than N rows"). */
|
|
18
|
+
const TABLE_SCAN_BYTES = 8 * 1024 * 1024;
|
|
19
|
+
const extension = (name) => {
|
|
20
|
+
const dot = name.lastIndexOf(".");
|
|
21
|
+
return dot > 0 ? name.slice(dot + 1).toLowerCase() : "";
|
|
22
|
+
};
|
|
23
|
+
/** How an artifact previews in the terminal, or undefined when it only opens externally. */
|
|
24
|
+
export function previewKind(artifact) {
|
|
25
|
+
if (artifact.status !== "ready")
|
|
26
|
+
return undefined;
|
|
27
|
+
const ext = extension(artifact.fileName);
|
|
28
|
+
if (artifact.kind === "document" && (ext === "md" || ext === "markdown"))
|
|
29
|
+
return "markdown";
|
|
30
|
+
if (artifact.kind === "spreadsheet" && (ext === "csv" || ext === "tsv"))
|
|
31
|
+
return "table";
|
|
32
|
+
if (artifact.kind === "data")
|
|
33
|
+
return "json";
|
|
34
|
+
if (artifact.kind === "code")
|
|
35
|
+
return "text";
|
|
36
|
+
return undefined;
|
|
37
|
+
}
|
|
38
|
+
const LANGUAGES = {
|
|
39
|
+
py: "python",
|
|
40
|
+
js: "javascript",
|
|
41
|
+
ts: "typescript",
|
|
42
|
+
sql: "sql",
|
|
43
|
+
yaml: "yaml",
|
|
44
|
+
yml: "yaml",
|
|
45
|
+
xml: "xml",
|
|
46
|
+
r: "r",
|
|
47
|
+
json: "json",
|
|
48
|
+
geojson: "json",
|
|
49
|
+
};
|
|
50
|
+
/** Decodes at most `max` bytes as UTF-8 without a broken trailing character. */
|
|
51
|
+
const decode = (bytes, max) => Buffer.from(bytes.subarray(0, max)).toString("utf8").replace(/�+$/, "");
|
|
52
|
+
/**
|
|
53
|
+
* RFC 4180 rows (quotes, doubled quotes, newlines inside quotes, CRLF/LF/CR), up to `limit`, with
|
|
54
|
+
* the same parser the dataset ingestion uses (so the preview and `data_inspect` agree).
|
|
55
|
+
*/
|
|
56
|
+
export function parseDelimited(text, delimiter, limit = Number.POSITIVE_INFINITY) {
|
|
57
|
+
const rows = [];
|
|
58
|
+
let total = 0;
|
|
59
|
+
const parser = new CsvParser(delimiter, (row) => {
|
|
60
|
+
total++;
|
|
61
|
+
if (rows.length < limit)
|
|
62
|
+
rows.push(row);
|
|
63
|
+
});
|
|
64
|
+
parser.write(text.charCodeAt(0) === 0xfeff ? text.slice(1) : text);
|
|
65
|
+
parser.end();
|
|
66
|
+
return { rows, total, complete: true };
|
|
67
|
+
}
|
|
68
|
+
const thousands = (n) => n.toLocaleString("en-US");
|
|
69
|
+
/** The preview of an artifact from its leading bytes (the caller reads them, bounded). */
|
|
70
|
+
export function buildPreview(artifact, bytes, fileBytes) {
|
|
71
|
+
const size = fileBytes ?? bytes.length;
|
|
72
|
+
const kind = previewKind(artifact) ?? "text";
|
|
73
|
+
const ext = extension(artifact.fileName);
|
|
74
|
+
if (kind === "markdown")
|
|
75
|
+
return {
|
|
76
|
+
block: { kind: "markdown", text: decode(bytes, PREVIEW_MAX_BYTES) },
|
|
77
|
+
truncated: size > PREVIEW_MAX_BYTES || bytes.length > PREVIEW_MAX_BYTES,
|
|
78
|
+
};
|
|
79
|
+
if (kind === "table") {
|
|
80
|
+
const scanned = bytes.length >= TABLE_SCAN_BYTES || size > bytes.length;
|
|
81
|
+
// UTF-8, UTF-16 (BOM) or windows-1252, and `;` or `|` as well as `,` and TAB, like ingestion.
|
|
82
|
+
const { text } = decodeTextBytes(bytes.subarray(0, TABLE_SCAN_BYTES), scanned);
|
|
83
|
+
const parsed = parseDelimited(text, ext === "tsv" ? "\t" : detectDelimiter(text.slice(0, 64 * 1024)), PREVIEW_MAX_ROWS + 1);
|
|
84
|
+
const [header = [], ...body] = parsed.rows;
|
|
85
|
+
const columns = header.length;
|
|
86
|
+
const rows = body.slice(0, PREVIEW_MAX_ROWS);
|
|
87
|
+
const dataRows = Math.max(0, parsed.total - 1);
|
|
88
|
+
const shownColumns = Math.min(columns, PREVIEW_MAX_COLUMNS);
|
|
89
|
+
const truncated = dataRows > rows.length || columns > shownColumns || scanned;
|
|
90
|
+
return {
|
|
91
|
+
block: {
|
|
92
|
+
kind: "table",
|
|
93
|
+
columns: header.slice(0, PREVIEW_MAX_COLUMNS),
|
|
94
|
+
rows: rows.map((row) => row.slice(0, PREVIEW_MAX_COLUMNS)),
|
|
95
|
+
},
|
|
96
|
+
truncated,
|
|
97
|
+
...(truncated
|
|
98
|
+
? {
|
|
99
|
+
legend: `showing ${thousands(rows.length)} of ${scanned ? "more than " : ""}${thousands(dataRows)} rows · ${shownColumns} of ${columns} columns`,
|
|
100
|
+
}
|
|
101
|
+
: {}),
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
const text = decode(bytes, PREVIEW_MAX_BYTES);
|
|
105
|
+
const cutBytes = size > PREVIEW_MAX_BYTES || bytes.length > PREVIEW_MAX_BYTES;
|
|
106
|
+
if (kind === "json") {
|
|
107
|
+
let code = text;
|
|
108
|
+
if (!cutBytes)
|
|
109
|
+
try {
|
|
110
|
+
code = JSON.stringify(JSON.parse(text), null, 2);
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
/* Invalid JSON is shown as it is. */
|
|
114
|
+
}
|
|
115
|
+
return { block: { kind: "code", lang: "json", code }, truncated: cutBytes };
|
|
116
|
+
}
|
|
117
|
+
const lines = text.split(/\r?\n/);
|
|
118
|
+
const lang = LANGUAGES[ext];
|
|
119
|
+
return {
|
|
120
|
+
block: {
|
|
121
|
+
kind: "code",
|
|
122
|
+
code: lines.slice(0, PREVIEW_MAX_LINES).join("\n"),
|
|
123
|
+
...(lang ? { lang } : {}),
|
|
124
|
+
},
|
|
125
|
+
truncated: cutBytes || lines.length > PREVIEW_MAX_LINES,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
/** Reads the bytes a preview needs (never the whole of a large file). */
|
|
129
|
+
export async function loadPreview(artifact, path) {
|
|
130
|
+
const handle = await open(path, "r");
|
|
131
|
+
try {
|
|
132
|
+
const { size } = await handle.stat();
|
|
133
|
+
const want = Math.min(size, (previewKind(artifact) === "table" ? TABLE_SCAN_BYTES : PREVIEW_MAX_BYTES) + 4);
|
|
134
|
+
const buffer = Buffer.alloc(want);
|
|
135
|
+
const { bytesRead } = await handle.read(buffer, 0, want, 0);
|
|
136
|
+
return buildPreview(artifact, buffer.subarray(0, bytesRead), size);
|
|
137
|
+
}
|
|
138
|
+
finally {
|
|
139
|
+
await handle.close();
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* A bounded, scrollable preview panel: ↑/↓, PgUp/PgDn, Home/End scroll; `o` opens with the
|
|
144
|
+
* system app, `c` copies the path, `Esc` or `q` closes. The footer says `truncated` when cut.
|
|
145
|
+
*/
|
|
146
|
+
export class ArtifactPreview {
|
|
147
|
+
artifact;
|
|
148
|
+
preview;
|
|
149
|
+
options;
|
|
150
|
+
offset = 0;
|
|
151
|
+
lines = [];
|
|
152
|
+
renderedWidth = 0;
|
|
153
|
+
constructor(artifact, preview, options) {
|
|
154
|
+
this.artifact = artifact;
|
|
155
|
+
this.preview = preview;
|
|
156
|
+
this.options = options;
|
|
157
|
+
}
|
|
158
|
+
invalidate() {
|
|
159
|
+
this.renderedWidth = 0;
|
|
160
|
+
}
|
|
161
|
+
page() {
|
|
162
|
+
return Math.max(3, this.options.height());
|
|
163
|
+
}
|
|
164
|
+
clamp() {
|
|
165
|
+
this.offset = Math.max(0, Math.min(this.offset, Math.max(0, this.lines.length - this.page())));
|
|
166
|
+
}
|
|
167
|
+
handleInput(data) {
|
|
168
|
+
if (matchesKey(data, Key.escape) || data === "q")
|
|
169
|
+
return this.options.close();
|
|
170
|
+
if (data === "o")
|
|
171
|
+
return this.options.open();
|
|
172
|
+
if (data === "c")
|
|
173
|
+
return this.options.copy();
|
|
174
|
+
if (matchesKey(data, Key.down))
|
|
175
|
+
this.offset++;
|
|
176
|
+
else if (matchesKey(data, Key.up))
|
|
177
|
+
this.offset--;
|
|
178
|
+
else if (matchesKey(data, Key.pageDown))
|
|
179
|
+
this.offset += this.page() - 1;
|
|
180
|
+
else if (matchesKey(data, Key.pageUp))
|
|
181
|
+
this.offset -= this.page() - 1;
|
|
182
|
+
else if (matchesKey(data, Key.home))
|
|
183
|
+
this.offset = 0;
|
|
184
|
+
else if (matchesKey(data, Key.end))
|
|
185
|
+
this.offset = Number.MAX_SAFE_INTEGER;
|
|
186
|
+
this.clamp();
|
|
187
|
+
}
|
|
188
|
+
render(width) {
|
|
189
|
+
if (width !== this.renderedWidth) {
|
|
190
|
+
const { unicode } = terminalCapabilities({ env: process.env, columns: width, tty: true });
|
|
191
|
+
this.lines = renderUiBlock(this.preview.block, width, unicode).map((line) => truncateToWidth(line, width));
|
|
192
|
+
this.renderedWidth = width;
|
|
193
|
+
}
|
|
194
|
+
this.clamp();
|
|
195
|
+
const page = this.page();
|
|
196
|
+
const end = Math.min(this.lines.length, this.offset + page);
|
|
197
|
+
const position = this.lines.length
|
|
198
|
+
? `${this.offset + 1}–${end} of ${this.lines.length}`
|
|
199
|
+
: "empty";
|
|
200
|
+
const notes = [
|
|
201
|
+
position,
|
|
202
|
+
...(this.preview.legend ? [this.preview.legend] : []),
|
|
203
|
+
...(this.preview.truncated ? ["truncated"] : []),
|
|
204
|
+
].join(" · ");
|
|
205
|
+
return [
|
|
206
|
+
truncateToWidth(style.bold(style.yellow(`Preview · ${this.artifact.fileName}`)), width),
|
|
207
|
+
...this.lines.slice(this.offset, end),
|
|
208
|
+
truncateToWidth(style.dim(` ${notes}`), width),
|
|
209
|
+
truncateToWidth(style.dim(" ↑↓ PgUp/PgDn Home/End · o open · c copy path · Esc close"), width),
|
|
210
|
+
];
|
|
211
|
+
}
|
|
212
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Terminal presentation of artifacts and saved permissions (spec §16): the two-line announcement
|
|
3
|
+
* under a tool, `/artifacts` picker rows, filtering, the actions each artifact offers and the
|
|
4
|
+
* `/permissions` rows. Pure functions; `app.ts` wires them to pickers and the clipboard.
|
|
5
|
+
*/
|
|
6
|
+
import type { ArtifactKind, ArtifactRef, CapabilityGrantWire, InstallPreview } from "@alisio/sdk";
|
|
7
|
+
export declare const kindLabel: (kind: ArtifactKind) => string;
|
|
8
|
+
export declare const ARTIFACTS_HINT = "/artifacts to open, copy or reveal";
|
|
9
|
+
export declare function formatSize(bytes: number): string;
|
|
10
|
+
/** `~` for the home folder, then a cut in the MIDDLE so both ends of the path stay visible. */
|
|
11
|
+
export declare function displayPath(path: string, home: string, max: number): string;
|
|
12
|
+
/**
|
|
13
|
+
* The announcement of one artifact: `▤ Dashboard name 48 KB` and, below, the real local path
|
|
14
|
+
* (or `[D]`-style ASCII icons when the terminal has no Unicode). Colors come from the theme,
|
|
15
|
+
* which is plain under `NO_COLOR`.
|
|
16
|
+
*/
|
|
17
|
+
export declare function artifactLines(artifact: ArtifactRef, options: {
|
|
18
|
+
path?: string;
|
|
19
|
+
width: number;
|
|
20
|
+
unicode: boolean;
|
|
21
|
+
home: string;
|
|
22
|
+
}): string[];
|
|
23
|
+
/** `3 min`, `2 h`, `5 d` (or `now`). */
|
|
24
|
+
export declare function age(createdAt: number, now: number): string;
|
|
25
|
+
/** One `/artifacts` picker row: `Dashboard sales-dashboard.html 48 KB 4 min`. */
|
|
26
|
+
export declare const artifactRow: (artifact: ArtifactRef, now: number) => string;
|
|
27
|
+
/** Case-insensitive match of every filter word against name, title and kind label. */
|
|
28
|
+
export declare function matchArtifact(artifact: ArtifactRef, filter: string): boolean;
|
|
29
|
+
export type ArtifactAction = "preview" | "open" | "copy" | "reveal" | "export" | "sources" | "details" | "rerun" | "delete";
|
|
30
|
+
/**
|
|
31
|
+
* The actions of the second `/artifacts` picker, only those that apply: `Preview here` for
|
|
32
|
+
* text-like kinds, `Copy to workspace…` unless `--read-only`, `Reveal analysis sources` for
|
|
33
|
+
* `python_run` outputs and `Rerun` when `python_run` is available. Dashboards, PDFs and images
|
|
34
|
+
* only open with the system app. An expired artifact (its folder was removed by retention) keeps
|
|
35
|
+
* `Details` and `Rerun` (the script and inputs usually remain); a deleted one has no actions.
|
|
36
|
+
*/
|
|
37
|
+
export declare function artifactActions(artifact: ArtifactRef, options?: {
|
|
38
|
+
readOnly?: boolean;
|
|
39
|
+
sources?: boolean;
|
|
40
|
+
rerun?: boolean;
|
|
41
|
+
}): Array<{
|
|
42
|
+
value: ArtifactAction;
|
|
43
|
+
label: string;
|
|
44
|
+
}>;
|
|
45
|
+
/** `Details` of an artifact (provenance without paths or code), one `label value` per row. */
|
|
46
|
+
export declare function detailRows(artifact: ArtifactRef, provenance: Record<string, unknown>): Array<[string, string]>;
|
|
47
|
+
/** `Python analysis · allowed for this session · since 10:42 (web)`. */
|
|
48
|
+
export declare const permissionRow: (grant: CapabilityGrantWire) => string;
|
|
49
|
+
/** Title of the `analysis.install` approval (once or deny only). */
|
|
50
|
+
export declare const installApprovalTitle: (install: InstallPreview) => string;
|
|
51
|
+
/** Body of the `analysis.install` approval: the packages, the download estimate and the network. */
|
|
52
|
+
export declare function installApprovalDetail(install: InstallPreview): string;
|
|
53
|
+
/** Title of a capability approval in the TUI picker. */
|
|
54
|
+
export declare const capabilityApprovalTitle: (runtime: "managed" | "oci" | undefined) => string;
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import { style } from "./theme.js";
|
|
2
|
+
const LABELS = {
|
|
3
|
+
dashboard: "Dashboard",
|
|
4
|
+
document: "Document",
|
|
5
|
+
spreadsheet: "Spreadsheet",
|
|
6
|
+
image: "Image",
|
|
7
|
+
data: "Data",
|
|
8
|
+
code: "Text",
|
|
9
|
+
archive: "Archive",
|
|
10
|
+
file: "File",
|
|
11
|
+
};
|
|
12
|
+
const UNICODE = {
|
|
13
|
+
dashboard: "▤",
|
|
14
|
+
document: "¶",
|
|
15
|
+
spreadsheet: "▦",
|
|
16
|
+
image: "◩",
|
|
17
|
+
data: "◇",
|
|
18
|
+
code: "‹›",
|
|
19
|
+
archive: "▣",
|
|
20
|
+
file: "▢",
|
|
21
|
+
};
|
|
22
|
+
const ASCII = {
|
|
23
|
+
dashboard: "[D]",
|
|
24
|
+
document: "[M]",
|
|
25
|
+
spreadsheet: "[T]",
|
|
26
|
+
image: "[I]",
|
|
27
|
+
data: "[J]",
|
|
28
|
+
code: "[C]",
|
|
29
|
+
archive: "[Z]",
|
|
30
|
+
file: "[F]",
|
|
31
|
+
};
|
|
32
|
+
export const kindLabel = (kind) => LABELS[kind] ?? "File";
|
|
33
|
+
export const ARTIFACTS_HINT = "/artifacts to open, copy or reveal";
|
|
34
|
+
export function formatSize(bytes) {
|
|
35
|
+
if (bytes < 1024)
|
|
36
|
+
return `${bytes} B`;
|
|
37
|
+
if (bytes < 1024 * 1024)
|
|
38
|
+
return `${Math.max(1, Math.round(bytes / 1024))} KB`;
|
|
39
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
40
|
+
}
|
|
41
|
+
/** `~` for the home folder, then a cut in the MIDDLE so both ends of the path stay visible. */
|
|
42
|
+
export function displayPath(path, home, max) {
|
|
43
|
+
const sep = path.includes("\\") && !path.includes("/") ? "\\" : "/";
|
|
44
|
+
let shown = home && (path === home || path.startsWith(`${home}${sep}`))
|
|
45
|
+
? `~${path.slice(home.length)}`
|
|
46
|
+
: path;
|
|
47
|
+
const chars = [...shown];
|
|
48
|
+
if (max >= 5 && chars.length > max) {
|
|
49
|
+
const keep = max - 1;
|
|
50
|
+
const head = Math.ceil(keep / 2);
|
|
51
|
+
shown = `${chars.slice(0, head).join("")}…${chars.slice(chars.length - (keep - head)).join("")}`;
|
|
52
|
+
}
|
|
53
|
+
return shown;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* The announcement of one artifact: `▤ Dashboard name 48 KB` and, below, the real local path
|
|
57
|
+
* (or `[D]`-style ASCII icons when the terminal has no Unicode). Colors come from the theme,
|
|
58
|
+
* which is plain under `NO_COLOR`.
|
|
59
|
+
*/
|
|
60
|
+
export function artifactLines(artifact, options) {
|
|
61
|
+
const icon = (options.unicode ? UNICODE : ASCII)[artifact.kind] ?? (options.unicode ? "▢" : "[F]");
|
|
62
|
+
const status = artifact.status !== "ready"
|
|
63
|
+
? ` ${style.yellow(`(${artifact.status})`)}`
|
|
64
|
+
: artifact.partial
|
|
65
|
+
? ` ${style.yellow("(partial)")}`
|
|
66
|
+
: "";
|
|
67
|
+
const lines = [
|
|
68
|
+
` ${style.cyan(icon)} ${style.bold(kindLabel(artifact.kind))} ${artifact.fileName} ${style.gray(formatSize(artifact.bytes))}${status}`,
|
|
69
|
+
];
|
|
70
|
+
if (options.path && artifact.status === "ready")
|
|
71
|
+
lines.push(` ${style.gray(displayPath(options.path, options.home, Math.max(10, options.width - 4)))}`);
|
|
72
|
+
return lines;
|
|
73
|
+
}
|
|
74
|
+
/** `3 min`, `2 h`, `5 d` (or `now`). */
|
|
75
|
+
export function age(createdAt, now) {
|
|
76
|
+
const seconds = Math.max(0, Math.round((now - createdAt) / 1000));
|
|
77
|
+
if (seconds < 60)
|
|
78
|
+
return "now";
|
|
79
|
+
if (seconds < 3600)
|
|
80
|
+
return `${Math.round(seconds / 60)} min`;
|
|
81
|
+
if (seconds < 86_400)
|
|
82
|
+
return `${Math.round(seconds / 3600)} h`;
|
|
83
|
+
return `${Math.round(seconds / 86_400)} d`;
|
|
84
|
+
}
|
|
85
|
+
/** One `/artifacts` picker row: `Dashboard sales-dashboard.html 48 KB 4 min`. */
|
|
86
|
+
export const artifactRow = (artifact, now) => `${kindLabel(artifact.kind).padEnd(11)} ${artifact.fileName} ${formatSize(artifact.bytes)} ${age(artifact.createdAt, now)}${artifact.partial ? " partial" : ""}${artifact.status === "expired" ? " Expired" : artifact.status === "deleted" ? " Deleted" : ""}`;
|
|
87
|
+
/** Case-insensitive match of every filter word against name, title and kind label. */
|
|
88
|
+
export function matchArtifact(artifact, filter) {
|
|
89
|
+
const haystack = `${artifact.fileName} ${artifact.title} ${kindLabel(artifact.kind)} ${artifact.kind}`.toLowerCase();
|
|
90
|
+
return filter
|
|
91
|
+
.toLowerCase()
|
|
92
|
+
.split(/\s+/)
|
|
93
|
+
.filter(Boolean)
|
|
94
|
+
.every((word) => haystack.includes(word));
|
|
95
|
+
}
|
|
96
|
+
/** Text-like kinds the terminal previews (Markdown, CSV/TSV, JSON, text/code). */
|
|
97
|
+
function previewsHere(artifact) {
|
|
98
|
+
const ext = artifact.fileName.toLowerCase().split(".").pop() ?? "";
|
|
99
|
+
return ((artifact.kind === "document" && (ext === "md" || ext === "markdown")) ||
|
|
100
|
+
(artifact.kind === "spreadsheet" && (ext === "csv" || ext === "tsv")) ||
|
|
101
|
+
artifact.kind === "data" ||
|
|
102
|
+
artifact.kind === "code");
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* The actions of the second `/artifacts` picker, only those that apply: `Preview here` for
|
|
106
|
+
* text-like kinds, `Copy to workspace…` unless `--read-only`, `Reveal analysis sources` for
|
|
107
|
+
* `python_run` outputs and `Rerun` when `python_run` is available. Dashboards, PDFs and images
|
|
108
|
+
* only open with the system app. An expired artifact (its folder was removed by retention) keeps
|
|
109
|
+
* `Details` and `Rerun` (the script and inputs usually remain); a deleted one has no actions.
|
|
110
|
+
*/
|
|
111
|
+
export function artifactActions(artifact, options = {}) {
|
|
112
|
+
if (artifact.status === "deleted")
|
|
113
|
+
return [];
|
|
114
|
+
const rerun = options.rerun
|
|
115
|
+
? [{ value: "rerun", label: "Rerun (same script and inputs)" }]
|
|
116
|
+
: [];
|
|
117
|
+
if (artifact.status !== "ready")
|
|
118
|
+
return [{ value: "details", label: "Details" }, ...rerun];
|
|
119
|
+
return [
|
|
120
|
+
...(previewsHere(artifact) ? [{ value: "preview", label: "Preview here" }] : []),
|
|
121
|
+
{ value: "open", label: "Open with default app" },
|
|
122
|
+
{ value: "copy", label: "Copy path" },
|
|
123
|
+
{ value: "reveal", label: "Reveal in folder" },
|
|
124
|
+
...(options.readOnly ? [] : [{ value: "export", label: "Copy to workspace…" }]),
|
|
125
|
+
...(options.sources ? [{ value: "sources", label: "Reveal analysis sources" }] : []),
|
|
126
|
+
{ value: "details", label: "Details" },
|
|
127
|
+
...rerun,
|
|
128
|
+
{ value: "delete", label: "Delete" },
|
|
129
|
+
];
|
|
130
|
+
}
|
|
131
|
+
/** `Details` of an artifact (provenance without paths or code), one `label value` per row. */
|
|
132
|
+
export function detailRows(artifact, provenance) {
|
|
133
|
+
const runtime = provenance.runtime;
|
|
134
|
+
const inputs = Array.isArray(provenance.inputs)
|
|
135
|
+
? provenance.inputs
|
|
136
|
+
: [];
|
|
137
|
+
const text = (value) => (typeof value === "string" && value ? value : undefined);
|
|
138
|
+
const rows = [
|
|
139
|
+
["Kind", kindLabel(artifact.kind)],
|
|
140
|
+
["Created", new Date(artifact.createdAt).toLocaleString()],
|
|
141
|
+
[
|
|
142
|
+
"Size",
|
|
143
|
+
`${formatSize(artifact.bytes)}${artifact.fileCount > 1 ? ` · ${artifact.fileCount} files` : ""}`,
|
|
144
|
+
],
|
|
145
|
+
["Model", text(provenance.model)],
|
|
146
|
+
["Provider", text(provenance.provider)],
|
|
147
|
+
[
|
|
148
|
+
"Runtime",
|
|
149
|
+
runtime
|
|
150
|
+
? [
|
|
151
|
+
runtime.mode,
|
|
152
|
+
runtime.engine,
|
|
153
|
+
runtime.python && `Python ${runtime.python}`,
|
|
154
|
+
runtime.image && runtime.image.replace(/(@sha256:[a-f0-9]{12})[a-f0-9]+$/, "$1…"),
|
|
155
|
+
runtime.extras?.join(", "),
|
|
156
|
+
]
|
|
157
|
+
.filter(Boolean)
|
|
158
|
+
.join(" · ")
|
|
159
|
+
: undefined,
|
|
160
|
+
],
|
|
161
|
+
[
|
|
162
|
+
"Inputs",
|
|
163
|
+
inputs.length
|
|
164
|
+
? inputs.map((i) => `${i.name} (${String(i.sha256 ?? "").slice(0, 12)})`).join(", ")
|
|
165
|
+
: undefined,
|
|
166
|
+
],
|
|
167
|
+
["Execution", text(provenance.executionId)],
|
|
168
|
+
["Rerun of", text(provenance.rerunOf)],
|
|
169
|
+
["Partial", artifact.partial ? "yes" : undefined],
|
|
170
|
+
[
|
|
171
|
+
"Status",
|
|
172
|
+
artifact.status === "ready"
|
|
173
|
+
? undefined
|
|
174
|
+
: artifact.status === "expired"
|
|
175
|
+
? "Expired (files removed by retention)"
|
|
176
|
+
: artifact.status,
|
|
177
|
+
],
|
|
178
|
+
];
|
|
179
|
+
return rows.filter((row) => !!row[1]);
|
|
180
|
+
}
|
|
181
|
+
const CAPABILITY_LABELS = {
|
|
182
|
+
"analysis.run": "Python analysis",
|
|
183
|
+
"analysis.install": "Python packages",
|
|
184
|
+
};
|
|
185
|
+
/** `HH:MM` local time. */
|
|
186
|
+
const clock = (time) => {
|
|
187
|
+
const date = new Date(time);
|
|
188
|
+
return `${String(date.getHours()).padStart(2, "0")}:${String(date.getMinutes()).padStart(2, "0")}`;
|
|
189
|
+
};
|
|
190
|
+
/** `Python analysis · allowed for this session · since 10:42 (web)`. */
|
|
191
|
+
export const permissionRow = (grant) => `${CAPABILITY_LABELS[grant.capability] ?? grant.capability} · allowed for this session · since ${clock(grant.createdAt)} (${grant.source})`;
|
|
192
|
+
/** Title of the `analysis.install` approval (once or deny only). */
|
|
193
|
+
export const installApprovalTitle = (install) => `Install Python packages (${install.extras}; needs network)?`;
|
|
194
|
+
/** Body of the `analysis.install` approval: the packages, the download estimate and the network. */
|
|
195
|
+
export function installApprovalDetail(install) {
|
|
196
|
+
return [
|
|
197
|
+
`Optional Python packages for this analysis: ${install.packages.join(", ")}`,
|
|
198
|
+
`(+ dependencies: ${install.packageCount} pinned wheels in total).`,
|
|
199
|
+
`Download: about ${formatSize(install.estimatedBytes)}. This needs network access.`,
|
|
200
|
+
"Wheels only, hash-verified, installed into a private environment under Alisio's state folder; nothing is compiled.",
|
|
201
|
+
"This permission is never remembered: it asks every time.",
|
|
202
|
+
].join("\n");
|
|
203
|
+
}
|
|
204
|
+
/** Title of a capability approval in the TUI picker. */
|
|
205
|
+
export const capabilityApprovalTitle = (runtime) => runtime === "oci"
|
|
206
|
+
? "Run Python analysis (container · no network)?"
|
|
207
|
+
: "Run Python analysis (managed · not sandboxed)?";
|
package/dist/tui/components.d.ts
CHANGED
|
@@ -22,6 +22,11 @@ export declare const exitCodeLine: (code: number | undefined, indent?: string) =
|
|
|
22
22
|
* `|-`/`` `- ``/`|` otherwise.
|
|
23
23
|
*/
|
|
24
24
|
export declare function renderTreeLines(nodes: TreeNode[], unicode: boolean): string[];
|
|
25
|
+
export declare function setArtifactPathResolver(resolve: ((id: string) => string | undefined) | undefined): void;
|
|
26
|
+
/** The two-line announcement of an `artifact` block (kind, name, size and local path). */
|
|
27
|
+
export declare function renderArtifactBlock(block: Extract<UiBlock, {
|
|
28
|
+
kind: "artifact";
|
|
29
|
+
}>, width: number, unicode: boolean, path?: string): string[];
|
|
25
30
|
/**
|
|
26
31
|
* Renders a `{type:"ui"}` block to styled lines for the given inner width. Blocks come from
|
|
27
32
|
* persisted transcripts too, so anything that does not validate (a kind added by a newer Alisio,
|
package/dist/tui/components.js
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
|
+
import { homedir } from "node:os";
|
|
1
2
|
import { Container, getCapabilities, getImageDimensions, Image, Key, Markdown, matchesKey, truncateToWidth, visibleWidth, wrapTextWithAnsi, } from "@earendil-works/pi-tui";
|
|
2
3
|
import { terminalCapabilities } from "../banner.js";
|
|
4
|
+
import { artifactLines } from "./artifacts.js";
|
|
3
5
|
import { attachmentCaption, MAX_ATTACHMENTS_PER_MESSAGE, } from "./attachments.js";
|
|
4
6
|
import { btwCurrent, btwHints, btwPosition, btwUsageLine, btwWindow, reduceBtw, } from "./btw.js";
|
|
5
7
|
import { branchDisplay } from "./git-branch.js";
|
|
6
8
|
import { initialQuestionState, reduceQuestions, } from "./questions.js";
|
|
7
|
-
import { contextLevel, contextPercent, editSummary, entryKeyOf, fitIdentityParts, fitSegments, foldCandidateOf, formatContext, formatDuration, formatTokens, groupIdentity, groupToolEntries, humanizeToolName, isUiBlock, previewLinesFor, previewRows, REASONING_EXPAND_MAX_LINES, reasoningDurationMs, } from "./state.js";
|
|
9
|
+
import { contextLevel, contextPercent, editSummary, entryKeyOf, fitIdentityParts, fitSegments, foldCandidateOf, formatContext, formatDuration, formatTokens, groupIdentity, groupToolEntries, humanizeToolName, isUiBlock, previewLinesFor, previewRows, quietFor, REASONING_EXPAND_MAX_LINES, reasoningDurationMs, runPhase, STALLED_MS, } from "./state.js";
|
|
8
10
|
import { highlightCode, imageTheme, levelColor, markdownDefaultTextStyle, markdownTheme, markdownTransform, sanitizeCode, style, } from "./theme.js";
|
|
9
11
|
export const SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
10
12
|
/** Shared animation clock advanced by the app while work is running. */
|
|
@@ -289,6 +291,22 @@ function renderUnknownBlock(block) {
|
|
|
289
291
|
: "unknown";
|
|
290
292
|
return [style.gray(`[ui block: ${kind}]`), ...renderCappedCode(jsonText(block), "json")];
|
|
291
293
|
}
|
|
294
|
+
/** Local path of a published artifact (set by the TUI from the artifact store). */
|
|
295
|
+
let artifactPath;
|
|
296
|
+
export function setArtifactPathResolver(resolve) {
|
|
297
|
+
artifactPath = resolve;
|
|
298
|
+
}
|
|
299
|
+
/** The two-line announcement of an `artifact` block (kind, name, size and local path). */
|
|
300
|
+
export function renderArtifactBlock(block, width, unicode, path) {
|
|
301
|
+
return artifactLines(block.artifact, {
|
|
302
|
+
width,
|
|
303
|
+
unicode,
|
|
304
|
+
home: homedir(),
|
|
305
|
+
...((path ?? artifactPath?.(block.artifact.id))
|
|
306
|
+
? { path: path ?? artifactPath?.(block.artifact.id) }
|
|
307
|
+
: {}),
|
|
308
|
+
});
|
|
309
|
+
}
|
|
292
310
|
/**
|
|
293
311
|
* Renders a `{type:"ui"}` block to styled lines for the given inner width. Blocks come from
|
|
294
312
|
* persisted transcripts too, so anything that does not validate (a kind added by a newer Alisio,
|
|
@@ -326,6 +344,8 @@ export function renderUiBlock(block, width, unicode) {
|
|
|
326
344
|
return renderTestResultsBlock(block, width, unicode);
|
|
327
345
|
case "progress":
|
|
328
346
|
return renderProgressBlock(block, unicode);
|
|
347
|
+
case "artifact":
|
|
348
|
+
return renderArtifactBlock(block, width, unicode);
|
|
329
349
|
default:
|
|
330
350
|
return renderUnknownBlock(block);
|
|
331
351
|
}
|
|
@@ -417,10 +437,14 @@ export class Footer {
|
|
|
417
437
|
const bar = pct === undefined
|
|
418
438
|
? style.gray("░".repeat(cells))
|
|
419
439
|
: levelColor(level)("█".repeat(filled)) + style.gray("░".repeat(cells - filled));
|
|
440
|
+
const quiet = v.streaming ? quietFor(v, clock.now) : 0;
|
|
441
|
+
const quietNote = quiet
|
|
442
|
+
? ` · no response for ${formatDuration(quiet)}${quiet >= STALLED_MS ? " (stuck?)" : ""}`
|
|
443
|
+
: "";
|
|
420
444
|
const state = v.compacting
|
|
421
445
|
? `${SPINNER[clock.frame % SPINNER.length]} compacting`
|
|
422
446
|
: v.streaming
|
|
423
|
-
? `${SPINNER[clock.frame % SPINNER.length]}
|
|
447
|
+
? `${SPINNER[clock.frame % SPINNER.length]} ${runPhase(v).label} ${formatDuration(clock.now - (v.runStartedAt ?? clock.now))}${quietNote}`
|
|
424
448
|
: "● idle";
|
|
425
449
|
const s = v.stats;
|
|
426
450
|
const segments = [
|
|
@@ -429,7 +453,15 @@ export class Footer {
|
|
|
429
453
|
priority: 10,
|
|
430
454
|
paint: pct === undefined ? style.gray : levelColor(level),
|
|
431
455
|
},
|
|
432
|
-
{
|
|
456
|
+
{
|
|
457
|
+
text: state,
|
|
458
|
+
priority: 9,
|
|
459
|
+
paint: quiet >= STALLED_MS
|
|
460
|
+
? style.yellow
|
|
461
|
+
: v.streaming || v.compacting
|
|
462
|
+
? style.cyan
|
|
463
|
+
: style.green,
|
|
464
|
+
},
|
|
433
465
|
{
|
|
434
466
|
text: `↑${formatTokens(s.input)} ↓${formatTokens(s.output)}${s.cached ? ` ⚡${formatTokens(s.cached)}` : ""}`,
|
|
435
467
|
priority: 6,
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Opening and revealing local files with the desktop's default application, portably and without
|
|
3
|
+
* a shell: arguments are an array (no `cmd /c start`, no `sh -c`), so a file name can never inject
|
|
4
|
+
* a command. Only paths returned by the artifact store are opened, never a path the model wrote.
|
|
5
|
+
*/
|
|
6
|
+
import { type ChildProcess, spawn } from "node:child_process";
|
|
7
|
+
export interface Launch {
|
|
8
|
+
command: string;
|
|
9
|
+
args: string[];
|
|
10
|
+
}
|
|
11
|
+
export type OpenOutcome = {
|
|
12
|
+
ok: true;
|
|
13
|
+
} | {
|
|
14
|
+
ok: false;
|
|
15
|
+
reason: "no-desktop" | "unavailable";
|
|
16
|
+
};
|
|
17
|
+
export interface OpenDeps {
|
|
18
|
+
platform?: NodeJS.Platform;
|
|
19
|
+
env?: NodeJS.ProcessEnv;
|
|
20
|
+
spawn?: (command: string, args: string[], options: Parameters<typeof spawn>[2]) => ChildProcess;
|
|
21
|
+
}
|
|
22
|
+
/** Commands that open `path` with the default app, in fallback order. */
|
|
23
|
+
export declare function openCommands(path: string, platform: NodeJS.Platform): Launch[];
|
|
24
|
+
/** Commands that show `path` selected in the file manager (its folder on Linux/BSD). */
|
|
25
|
+
export declare function revealCommands(path: string, platform: NodeJS.Platform): Launch[];
|
|
26
|
+
/**
|
|
27
|
+
* Whether a desktop session can show a window: never over SSH; on Linux/BSD only with a display
|
|
28
|
+
* (`DISPLAY` or `WAYLAND_DISPLAY`). macOS and Windows always have one locally.
|
|
29
|
+
*/
|
|
30
|
+
export declare function desktopAvailable(platform: NodeJS.Platform, env: NodeJS.ProcessEnv): boolean;
|
|
31
|
+
/** Opens a file with the default application. */
|
|
32
|
+
export declare const openPath: (path: string, deps?: OpenDeps) => Promise<OpenOutcome>;
|
|
33
|
+
/** Shows a file in the system file manager. */
|
|
34
|
+
export declare const revealPath: (path: string, deps?: OpenDeps) => Promise<OpenOutcome>;
|
|
35
|
+
/** The notice shown when nothing could be opened (the caller offers "Copy path"). */
|
|
36
|
+
export declare const cannotOpenMessage: (path: string, outcome: Exclude<OpenOutcome, {
|
|
37
|
+
ok: true;
|
|
38
|
+
}>) => string;
|