@alisio/alisio-code 0.1.0-alpha.9 → 0.1.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.
Files changed (56) hide show
  1. package/README.md +95 -16
  2. package/dist/analysis.d.ts +27 -0
  3. package/dist/analysis.js +145 -0
  4. package/dist/banner.js +2 -1
  5. package/dist/builtin.d.ts +3 -1
  6. package/dist/builtin.js +0 -24
  7. package/dist/main.js +133 -6
  8. package/dist/serve.d.ts +15 -0
  9. package/dist/serve.js +99 -0
  10. package/dist/tui/agent-manager.d.ts +105 -0
  11. package/dist/tui/agent-manager.js +269 -0
  12. package/dist/tui/agents.d.ts +5 -0
  13. package/dist/tui/agents.js +5 -0
  14. package/dist/tui/app.d.ts +42 -1
  15. package/dist/tui/app.js +1895 -97
  16. package/dist/tui/artifact-preview.d.ts +52 -0
  17. package/dist/tui/artifact-preview.js +212 -0
  18. package/dist/tui/artifacts.d.ts +54 -0
  19. package/dist/tui/artifacts.js +207 -0
  20. package/dist/tui/btw.d.ts +57 -0
  21. package/dist/tui/btw.js +76 -0
  22. package/dist/tui/components.d.ts +174 -12
  23. package/dist/tui/components.js +962 -66
  24. package/dist/tui/connect-input.d.ts +2 -0
  25. package/dist/tui/connect-input.js +1 -1
  26. package/dist/tui/exit.d.ts +10 -0
  27. package/dist/tui/exit.js +15 -0
  28. package/dist/tui/git-branch.d.ts +48 -0
  29. package/dist/tui/git-branch.js +99 -0
  30. package/dist/tui/goal.d.ts +75 -0
  31. package/dist/tui/goal.js +171 -0
  32. package/dist/tui/modes.d.ts +71 -0
  33. package/dist/tui/modes.js +90 -0
  34. package/dist/tui/open-path.d.ts +38 -0
  35. package/dist/tui/open-path.js +80 -0
  36. package/dist/tui/plan-review.d.ts +48 -0
  37. package/dist/tui/plan-review.js +76 -0
  38. package/dist/tui/queue.d.ts +2 -0
  39. package/dist/tui/queue.js +5 -1
  40. package/dist/tui/scroll.d.ts +8 -0
  41. package/dist/tui/scroll.js +12 -0
  42. package/dist/tui/settings-menu.d.ts +40 -0
  43. package/dist/tui/settings-menu.js +132 -0
  44. package/dist/tui/settings.d.ts +194 -0
  45. package/dist/tui/settings.js +482 -0
  46. package/dist/tui/state.d.ts +343 -7
  47. package/dist/tui/state.js +823 -54
  48. package/dist/tui/tasks.d.ts +123 -0
  49. package/dist/tui/tasks.js +369 -0
  50. package/dist/tui/theme.d.ts +42 -1
  51. package/dist/tui/theme.js +516 -1
  52. package/dist/tui/viewer-state.d.ts +7 -0
  53. package/dist/tui/viewer-state.js +36 -0
  54. package/dist/version.d.ts +7 -5
  55. package/dist/version.js +44 -8
  56. package/package.json +7 -9
@@ -0,0 +1,52 @@
1
+ import type { ArtifactRef, UiBlock } from "@alisio/sdk";
2
+ import { type Component } from "@earendil-works/pi-tui";
3
+ export declare const PREVIEW_MAX_BYTES: number;
4
+ export declare const PREVIEW_MAX_LINES = 2000;
5
+ export declare const PREVIEW_MAX_ROWS = 200;
6
+ export declare const PREVIEW_MAX_COLUMNS = 20;
7
+ export type PreviewKind = "markdown" | "table" | "json" | "text";
8
+ /** How an artifact previews in the terminal, or undefined when it only opens externally. */
9
+ export declare function previewKind(artifact: ArtifactRef): PreviewKind | undefined;
10
+ export interface Preview {
11
+ block: UiBlock;
12
+ truncated: boolean;
13
+ /** Table legend: `showing 200 of 12,480 rows · 20 of 31 columns`. */
14
+ legend?: string;
15
+ }
16
+ /**
17
+ * RFC 4180 rows (quotes, doubled quotes, newlines inside quotes, CRLF/LF/CR), up to `limit`, with
18
+ * the same parser the dataset ingestion uses (so the preview and `data_inspect` agree).
19
+ */
20
+ export declare function parseDelimited(text: string, delimiter: string, limit?: number): {
21
+ rows: string[][];
22
+ total: number;
23
+ complete: boolean;
24
+ };
25
+ /** The preview of an artifact from its leading bytes (the caller reads them, bounded). */
26
+ export declare function buildPreview(artifact: ArtifactRef, bytes: Uint8Array, fileBytes?: number): Preview;
27
+ /** Reads the bytes a preview needs (never the whole of a large file). */
28
+ export declare function loadPreview(artifact: ArtifactRef, path: string): Promise<Preview>;
29
+ /**
30
+ * A bounded, scrollable preview panel: ↑/↓, PgUp/PgDn, Home/End scroll; `o` opens with the
31
+ * system app, `c` copies the path, `Esc` or `q` closes. The footer says `truncated` when cut.
32
+ */
33
+ export declare class ArtifactPreview implements Component {
34
+ private readonly artifact;
35
+ private readonly preview;
36
+ private readonly options;
37
+ private offset;
38
+ private lines;
39
+ private renderedWidth;
40
+ constructor(artifact: ArtifactRef, preview: Preview, options: {
41
+ /** Rows available for the body (the panel adds a title and a footer). */
42
+ height: () => number;
43
+ open: () => void;
44
+ copy: () => void;
45
+ close: () => void;
46
+ });
47
+ invalidate(): void;
48
+ private page;
49
+ private clamp;
50
+ handleInput(data: string): void;
51
+ render(width: number): string[];
52
+ }
@@ -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)?";
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Pure state of the TUI `/btw` panel: a pending side question, its answer, and browsing earlier
3
+ * answers of the session. The panel lives in the picker slot and never touches the transcript.
4
+ *
5
+ * Keys: ←/→ earlier/later answer, ↑/↓ (and PgUp/PgDn) scroll a long answer, Esc cancels a pending
6
+ * question or closes the panel.
7
+ */
8
+ import type { SideQuestionEntry } from "@alisio/sdk";
9
+ import { scrollWindow } from "./scroll.ts";
10
+ export declare const BTW_USAGE = "Usage: /btw <question>";
11
+ export declare const BTW_DESCRIPTION = "Ask a side question about the current session without adding to the conversation.";
12
+ export interface BtwState {
13
+ entries: SideQuestionEntry[];
14
+ /** Index of the entry on screen (ignored while `pending`). */
15
+ index: number;
16
+ /** A question waiting for its answer. */
17
+ pending?: {
18
+ question: string;
19
+ startedAt: number;
20
+ };
21
+ /** Why the last question failed (shown until the user navigates). */
22
+ error?: {
23
+ question: string;
24
+ message: string;
25
+ };
26
+ /** First answer line on screen. */
27
+ scroll: number;
28
+ }
29
+ export type BtwAction = {
30
+ type: "prev";
31
+ } | {
32
+ type: "next";
33
+ } | {
34
+ type: "scroll";
35
+ lines: number;
36
+ } | {
37
+ type: "answered";
38
+ entries: SideQuestionEntry[];
39
+ } | {
40
+ type: "failed";
41
+ message: string;
42
+ };
43
+ /** The browser for `/btw` without a question: the newest entry, or undefined when none. */
44
+ export declare function btwBrowser(entries: SideQuestionEntry[]): BtwState | undefined;
45
+ /** The panel while `question` is being answered (earlier entries stay browsable afterwards). */
46
+ export declare function btwPending(entries: SideQuestionEntry[], question: string, now?: number): BtwState;
47
+ export declare function reduceBtw(state: BtwState, action: BtwAction): BtwState;
48
+ /** The entry on screen, if the panel is not pending or showing an error. */
49
+ export declare function btwCurrent(state: BtwState): SideQuestionEntry | undefined;
50
+ /** "2/5" for the entry on screen; empty while pending or on an error. */
51
+ export declare function btwPosition(state: BtwState): string;
52
+ /** Footer hints for the current state. */
53
+ export declare function btwHints(state: BtwState, overflow: boolean): string;
54
+ /** "model · 12 in · 3 out tokens" for an entry. */
55
+ export declare function btwUsageLine(entry: SideQuestionEntry): string;
56
+ /** The visible slice of `lines` for `scroll` (clamped) and whether more lines exist. */
57
+ export declare const btwWindow: typeof scrollWindow;
@@ -0,0 +1,76 @@
1
+ import { scrollWindow } from "./scroll.js";
2
+ export const BTW_USAGE = "Usage: /btw <question>";
3
+ export const BTW_DESCRIPTION = "Ask a side question about the current session without adding to the conversation.";
4
+ /** The browser for `/btw` without a question: the newest entry, or undefined when none. */
5
+ export function btwBrowser(entries) {
6
+ return entries.length ? { entries, index: entries.length - 1, scroll: 0 } : undefined;
7
+ }
8
+ /** The panel while `question` is being answered (earlier entries stay browsable afterwards). */
9
+ export function btwPending(entries, question, now = Date.now()) {
10
+ return {
11
+ entries,
12
+ index: Math.max(0, entries.length - 1),
13
+ pending: { question, startedAt: now },
14
+ scroll: 0,
15
+ };
16
+ }
17
+ export function reduceBtw(state, action) {
18
+ switch (action.type) {
19
+ case "prev":
20
+ case "next": {
21
+ if (state.pending || !state.entries.length)
22
+ return state;
23
+ const step = action.type === "prev" ? -1 : 1;
24
+ // Leaving an error shows the stored entries again (the newest first).
25
+ const from = state.error ? state.entries.length : state.index;
26
+ const index = Math.min(state.entries.length - 1, Math.max(0, from + step));
27
+ if (index === state.index && !state.error)
28
+ return state;
29
+ return { entries: state.entries, index, scroll: 0 };
30
+ }
31
+ case "scroll":
32
+ return { ...state, scroll: Math.max(0, state.scroll + action.lines) };
33
+ case "answered":
34
+ return {
35
+ entries: action.entries,
36
+ index: Math.max(0, action.entries.length - 1),
37
+ scroll: 0,
38
+ };
39
+ case "failed": {
40
+ const { pending: _pending, ...rest } = state;
41
+ return {
42
+ ...rest,
43
+ error: { question: state.pending?.question ?? "", message: action.message },
44
+ scroll: 0,
45
+ };
46
+ }
47
+ }
48
+ }
49
+ /** The entry on screen, if the panel is not pending or showing an error. */
50
+ export function btwCurrent(state) {
51
+ return state.pending || state.error ? undefined : state.entries[state.index];
52
+ }
53
+ /** "2/5" for the entry on screen; empty while pending or on an error. */
54
+ export function btwPosition(state) {
55
+ return btwCurrent(state) ? `${state.index + 1}/${state.entries.length}` : "";
56
+ }
57
+ /** Footer hints for the current state. */
58
+ export function btwHints(state, overflow) {
59
+ if (state.pending)
60
+ return "Esc cancel";
61
+ const current = btwCurrent(state);
62
+ const hints = [
63
+ ...(state.entries.length > 1 || (state.error && state.entries.length)
64
+ ? ["←/→ earlier/later"]
65
+ : []),
66
+ ...(overflow ? ["↑/↓ scroll"] : []),
67
+ "Esc close",
68
+ ];
69
+ return current || state.error ? hints.join(" · ") : "Esc close";
70
+ }
71
+ /** "model · 12 in · 3 out tokens" for an entry. */
72
+ export function btwUsageLine(entry) {
73
+ return `${entry.model} · ${entry.usage.input} in · ${entry.usage.output} out tokens${entry.truncated ? " · truncated" : ""}`;
74
+ }
75
+ /** The visible slice of `lines` for `scroll` (clamped) and whether more lines exist. */
76
+ export const btwWindow = scrollWindow;