@alisio/alisio-code 0.1.0-alpha.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.
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Image attachments for the TUI message composer: clipboard image paste (Ctrl+V), a bounded
3
+ * pending list shown above the editor, and the SDK content shape sent with the next message.
4
+ * Pure and dependency-injected so it is fully unit-testable; pi-tui APIs (native clipboard,
5
+ * image dimensions, rendering) are wired in by apps/cli/src/tui/app.ts.
6
+ */
7
+ import type { Attachment } from "@alisio/sdk";
8
+ /** Single image cap (raw bytes, before base64). */
9
+ export declare const MAX_IMAGE_BYTES: number;
10
+ /** Attachments allowed on one outgoing message. */
11
+ export declare const MAX_ATTACHMENTS_PER_MESSAGE = 4;
12
+ export interface PendingAttachment {
13
+ id: string;
14
+ mimeType: string;
15
+ /** Base64-encoded bytes, no `data:` prefix. */
16
+ data: string;
17
+ bytes: number;
18
+ width?: number;
19
+ height?: number;
20
+ }
21
+ export type AttachmentRejection = "empty" | "unsupported-format" | "too-large" | "too-many";
22
+ /** Detects PNG/JPEG/GIF/WebP by magic bytes; other formats are not supported. */
23
+ export declare function sniffImageMime(bytes: Uint8Array): string | undefined;
24
+ export interface BuildOptions {
25
+ maxBytes?: number;
26
+ /** Injected so the pure module never imports a pi-tui image decoder itself. */
27
+ dimensions?: (base64: string, mimeType: string) => {
28
+ widthPx: number;
29
+ heightPx: number;
30
+ } | null;
31
+ id?: () => string;
32
+ }
33
+ export declare function buildAttachment(bytes: Uint8Array, options?: BuildOptions): {
34
+ ok: true;
35
+ attachment: PendingAttachment;
36
+ } | {
37
+ ok: false;
38
+ error: AttachmentRejection;
39
+ };
40
+ export interface ListOptions extends BuildOptions {
41
+ maxCount?: number;
42
+ }
43
+ /** Appends within the count limit; the input list is never mutated. */
44
+ export declare function addAttachment(list: readonly PendingAttachment[], bytes: Uint8Array, options?: ListOptions): {
45
+ list: PendingAttachment[];
46
+ error?: AttachmentRejection;
47
+ };
48
+ export declare function removeLastAttachment(list: readonly PendingAttachment[]): {
49
+ list: PendingAttachment[];
50
+ removed?: PendingAttachment;
51
+ };
52
+ /** `[N] image/png 1024x768, 42.0 KB` — a compact fallback for terminals without inline images. */
53
+ export declare function attachmentCaption(a: PendingAttachment, index: number): string;
54
+ export declare function rejectionMessage(error: AttachmentRejection, options?: ListOptions): string;
55
+ /** The SDK content shape sent alongside the message text. */
56
+ export declare function toApiAttachment(a: PendingAttachment): Attachment;
57
+ export interface ClipboardImageSource {
58
+ getImage(): Promise<Uint8Array | null | undefined>;
59
+ }
60
+ /**
61
+ * Ctrl+V: undefined clipboard (no platform helper, e.g. plain SSH without X11/wl-clipboard) is a
62
+ * graceful no-op; `getImage()` resolving `null` means an empty (non-image) clipboard, `undefined`
63
+ * means the image source itself is unavailable; a rejected read is reported, never thrown.
64
+ */
65
+ export declare function pasteImageFromClipboard(clipboard: ClipboardImageSource | undefined, current: readonly PendingAttachment[], options?: ListOptions): Promise<{
66
+ list: PendingAttachment[];
67
+ message?: string;
68
+ }>;
@@ -0,0 +1,132 @@
1
+ /** Single image cap (raw bytes, before base64). */
2
+ export const MAX_IMAGE_BYTES = 5 * 1024 * 1024;
3
+ /** Attachments allowed on one outgoing message. */
4
+ export const MAX_ATTACHMENTS_PER_MESSAGE = 4;
5
+ const MAGIC = [
6
+ {
7
+ mime: "image/png",
8
+ test: (b) => b.length >= 8 && b[0] === 0x89 && b[1] === 0x50 && b[2] === 0x4e && b[3] === 0x47,
9
+ },
10
+ {
11
+ mime: "image/jpeg",
12
+ test: (b) => b.length >= 3 && b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff,
13
+ },
14
+ {
15
+ mime: "image/gif",
16
+ test: (b) => b.length >= 6 && b[0] === 0x47 && b[1] === 0x49 && b[2] === 0x46,
17
+ },
18
+ {
19
+ mime: "image/webp",
20
+ test: (b) => b.length >= 12 &&
21
+ b[0] === 0x52 &&
22
+ b[1] === 0x49 &&
23
+ b[2] === 0x46 &&
24
+ b[3] === 0x46 &&
25
+ b[8] === 0x57 &&
26
+ b[9] === 0x45 &&
27
+ b[10] === 0x42 &&
28
+ b[11] === 0x50,
29
+ },
30
+ ];
31
+ /** Detects PNG/JPEG/GIF/WebP by magic bytes; other formats are not supported. */
32
+ export function sniffImageMime(bytes) {
33
+ return MAGIC.find((m) => m.test(bytes))?.mime;
34
+ }
35
+ const defaultId = () => crypto.randomUUID();
36
+ export function buildAttachment(bytes, options = {}) {
37
+ if (!bytes.length)
38
+ return { ok: false, error: "empty" };
39
+ if (bytes.length > (options.maxBytes ?? MAX_IMAGE_BYTES))
40
+ return { ok: false, error: "too-large" };
41
+ const mimeType = sniffImageMime(bytes);
42
+ if (!mimeType)
43
+ return { ok: false, error: "unsupported-format" };
44
+ const data = Buffer.from(bytes).toString("base64");
45
+ const dims = options.dimensions?.(data, mimeType) ?? null;
46
+ return {
47
+ ok: true,
48
+ attachment: {
49
+ id: (options.id ?? defaultId)(),
50
+ mimeType,
51
+ data,
52
+ bytes: bytes.length,
53
+ ...(dims ? { width: dims.widthPx, height: dims.heightPx } : {}),
54
+ },
55
+ };
56
+ }
57
+ /** Appends within the count limit; the input list is never mutated. */
58
+ export function addAttachment(list, bytes, options = {}) {
59
+ if (list.length >= (options.maxCount ?? MAX_ATTACHMENTS_PER_MESSAGE))
60
+ return { list: [...list], error: "too-many" };
61
+ const result = buildAttachment(bytes, options);
62
+ if (!result.ok)
63
+ return { list: [...list], error: result.error };
64
+ return { list: [...list, result.attachment] };
65
+ }
66
+ export function removeLastAttachment(list) {
67
+ if (!list.length)
68
+ return { list: [] };
69
+ return { list: list.slice(0, -1), removed: list.at(-1) };
70
+ }
71
+ const kb = (bytes) => (bytes / 1024).toFixed(1);
72
+ /** `[N] image/png 1024x768, 42.0 KB` — a compact fallback for terminals without inline images. */
73
+ export function attachmentCaption(a, index) {
74
+ const dims = a.width && a.height ? `${a.width}x${a.height}, ` : "";
75
+ return `[${index + 1}] ${a.mimeType} ${dims}${kb(a.bytes)} KB`;
76
+ }
77
+ export function rejectionMessage(error, options = {}) {
78
+ const maxMb = (options.maxBytes ?? MAX_IMAGE_BYTES) / (1024 * 1024);
79
+ const maxCount = options.maxCount ?? MAX_ATTACHMENTS_PER_MESSAGE;
80
+ switch (error) {
81
+ case "empty":
82
+ return "The clipboard image is empty.";
83
+ case "unsupported-format":
84
+ return "Unsupported image format (only PNG, JPEG, GIF and WebP are recognized).";
85
+ case "too-large":
86
+ return `Image exceeds the ${maxMb} MB limit per attachment.`;
87
+ case "too-many":
88
+ return `Maximum ${maxCount} attachments per message.`;
89
+ }
90
+ }
91
+ /** The SDK content shape sent alongside the message text. */
92
+ export function toApiAttachment(a) {
93
+ return {
94
+ kind: "image",
95
+ mimeType: a.mimeType,
96
+ data: a.data,
97
+ bytes: a.bytes,
98
+ ...(a.width !== undefined ? { width: a.width } : {}),
99
+ ...(a.height !== undefined ? { height: a.height } : {}),
100
+ };
101
+ }
102
+ /**
103
+ * Ctrl+V: undefined clipboard (no platform helper, e.g. plain SSH without X11/wl-clipboard) is a
104
+ * graceful no-op; `getImage()` resolving `null` means an empty (non-image) clipboard, `undefined`
105
+ * means the image source itself is unavailable; a rejected read is reported, never thrown.
106
+ */
107
+ export async function pasteImageFromClipboard(clipboard, current, options = {}) {
108
+ if (!clipboard)
109
+ return {
110
+ list: [...current],
111
+ message: "No native clipboard access here (e.g. over SSH, or without a platform clipboard helper).",
112
+ };
113
+ let bytes;
114
+ try {
115
+ bytes = await clipboard.getImage();
116
+ }
117
+ catch (error) {
118
+ return {
119
+ list: [...current],
120
+ message: `Clipboard read failed: ${error instanceof Error ? error.message : String(error)}`,
121
+ };
122
+ }
123
+ if (bytes === undefined)
124
+ return {
125
+ list: [...current],
126
+ message: "Image clipboard access is not available on this platform or session.",
127
+ };
128
+ if (bytes === null)
129
+ return { list: [...current], message: "Clipboard has no image." };
130
+ const { list, error } = addAttachment(current, bytes, options);
131
+ return error ? { list, message: rejectionMessage(error, options) } : { list };
132
+ }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Clipboard adapter for the TUI. Command selection is pure and the process spawner is
3
+ * injected, so tests never touch a real clipboard.
4
+ */
5
+ /** Resolves with the exit code; rejects when the executable cannot be started. */
6
+ export type Spawn = (command: string, args: string[], input: string) => Promise<number>;
7
+ export interface ClipboardCommand {
8
+ command: string;
9
+ args: string[];
10
+ }
11
+ export interface CopyResult {
12
+ ok: boolean;
13
+ /** Tool that succeeded, `osc52` (unverified) or `none`. */
14
+ method: string;
15
+ }
16
+ export declare function clipboardCommands(platform: NodeJS.Platform, env: Record<string, string | undefined>): ClipboardCommand[];
17
+ export declare const osc52: (text: string) => string;
18
+ /**
19
+ * Tries native tools in order (stdin input, no shell) and succeeds only on exit code 0.
20
+ * Otherwise writes OSC 52 when a writer is available; that path cannot be verified, so it
21
+ * reports `ok: false` with method `osc52`.
22
+ */
23
+ export declare function copyText(text: string, deps: {
24
+ platform: NodeJS.Platform;
25
+ env: Record<string, string | undefined>;
26
+ spawn: Spawn;
27
+ writeOsc52?: (sequence: string) => void;
28
+ }): Promise<CopyResult>;
29
+ /** Real spawner (node:child_process): no shell, text on stdin, output ignored, 3s timeout. */
30
+ export declare const nodeSpawn: Spawn;
@@ -0,0 +1,57 @@
1
+ import { spawn } from "node:child_process";
2
+ export function clipboardCommands(platform, env) {
3
+ const windows = [
4
+ { command: "clip.exe", args: [] },
5
+ {
6
+ command: "powershell.exe",
7
+ args: ["-NoProfile", "-NonInteractive", "-Command", "Set-Clipboard -Value $input"],
8
+ },
9
+ ];
10
+ if (platform === "darwin")
11
+ return [{ command: "pbcopy", args: [] }];
12
+ if (platform === "win32")
13
+ return windows;
14
+ return [
15
+ { command: "wl-copy", args: [] },
16
+ { command: "xclip", args: ["-selection", "clipboard"] },
17
+ { command: "xsel", args: ["-b"] },
18
+ ...(env.WSL_DISTRO_NAME ? windows : []),
19
+ ];
20
+ }
21
+ export const osc52 = (text) => `\x1b]52;c;${Buffer.from(text, "utf8").toString("base64")}\x07`;
22
+ /**
23
+ * Tries native tools in order (stdin input, no shell) and succeeds only on exit code 0.
24
+ * Otherwise writes OSC 52 when a writer is available; that path cannot be verified, so it
25
+ * reports `ok: false` with method `osc52`.
26
+ */
27
+ export async function copyText(text, deps) {
28
+ for (const { command, args } of clipboardCommands(deps.platform, deps.env)) {
29
+ try {
30
+ if ((await deps.spawn(command, args, text)) === 0)
31
+ return { ok: true, method: command };
32
+ }
33
+ catch {
34
+ /* Not installed or not startable: try the next tool. */
35
+ }
36
+ }
37
+ if (deps.writeOsc52) {
38
+ deps.writeOsc52(osc52(text));
39
+ return { ok: false, method: "osc52" };
40
+ }
41
+ return { ok: false, method: "none" };
42
+ }
43
+ /** Real spawner (node:child_process): no shell, text on stdin, output ignored, 3s timeout. */
44
+ export const nodeSpawn = (command, args, input) => new Promise((resolveCode, reject) => {
45
+ const child = spawn(command, args, { stdio: ["pipe", "ignore", "ignore"], windowsHide: true });
46
+ const timer = setTimeout(() => child.kill(), 3_000);
47
+ child.once("error", (error) => {
48
+ clearTimeout(timer);
49
+ reject(error);
50
+ });
51
+ child.once("close", (code) => {
52
+ clearTimeout(timer);
53
+ resolveCode(code ?? 1);
54
+ });
55
+ child.stdin.on("error", () => { });
56
+ child.stdin.end(input);
57
+ });
@@ -0,0 +1,159 @@
1
+ import type { PanelNode } from "@alisio/sdk";
2
+ import { type Component, Container } from "@earendil-works/pi-tui";
3
+ import { type PendingAttachment } from "./attachments.ts";
4
+ import { type QuestionPanelState, type QuestionSpec } from "./questions.ts";
5
+ import { type ContextBudget, type TranscriptItem, type ViewState } from "./state.ts";
6
+ export declare const SPINNER: string[];
7
+ /** Shared animation clock advanced by the app while work is running. */
8
+ export declare const clock: {
9
+ frame: number;
10
+ now: number;
11
+ };
12
+ export type PermissionState = "on" | "ask" | "off";
13
+ export interface HeaderInfo {
14
+ version: string;
15
+ host: string;
16
+ apiMode: string;
17
+ provider?: string;
18
+ cwd: string;
19
+ session: string;
20
+ write: PermissionState;
21
+ process: PermissionState;
22
+ mcp: boolean;
23
+ readOnly: boolean;
24
+ }
25
+ export declare class Header implements Component {
26
+ private info;
27
+ private view;
28
+ constructor(info: () => HeaderInfo, view: () => ViewState);
29
+ invalidate(): void;
30
+ render(width: number): string[];
31
+ }
32
+ export declare class Footer implements Component {
33
+ private view;
34
+ private budget;
35
+ private hint;
36
+ private statuses;
37
+ constructor(view: () => ViewState, budget: () => ContextBudget | undefined, hint: () => string | undefined, statuses?: () => string[]);
38
+ invalidate(): void;
39
+ render(width: number): string[];
40
+ }
41
+ export declare class UserBlock implements Component {
42
+ private text;
43
+ constructor(text: string);
44
+ invalidate(): void;
45
+ render(width: number): string[];
46
+ }
47
+ export declare class AssistantBlock implements Component {
48
+ private markdown;
49
+ private item;
50
+ constructor(item: Extract<TranscriptItem, {
51
+ kind: "assistant";
52
+ }>);
53
+ update(item: Extract<TranscriptItem, {
54
+ kind: "assistant";
55
+ }>): void;
56
+ invalidate(): void;
57
+ render(width: number): string[];
58
+ }
59
+ export declare class ToolBlock implements Component {
60
+ item: Extract<TranscriptItem, {
61
+ kind: "tool";
62
+ }>;
63
+ constructor(item: Extract<TranscriptItem, {
64
+ kind: "tool";
65
+ }>);
66
+ invalidate(): void;
67
+ render(width: number): string[];
68
+ }
69
+ export declare class LineBlock implements Component {
70
+ private text;
71
+ private kind;
72
+ constructor(text: string, kind: "notice" | "error");
73
+ invalidate(): void;
74
+ render(width: number): string[];
75
+ }
76
+ export declare class InfoBlock implements Component {
77
+ private markdown;
78
+ constructor(text: string);
79
+ invalidate(): void;
80
+ render(width: number): string[];
81
+ }
82
+ export declare function componentFor(item: TranscriptItem): Component;
83
+ /** Startup screen rendered by core for the current width (cached until the width changes). */
84
+ export declare class BannerBlock implements Component {
85
+ private renderLines;
86
+ private cache?;
87
+ constructor(renderLines: (width: number) => string[]);
88
+ invalidate(): void;
89
+ render(width: number): string[];
90
+ }
91
+ /** Keeps a container of transcript components in step with view-model items. */
92
+ export declare class TranscriptSync {
93
+ readonly container: Container;
94
+ private rendered;
95
+ sync(items: TranscriptItem[]): void;
96
+ reset(): void;
97
+ }
98
+ /** Renders one of several components (main conversation or a read-only child view). */
99
+ export declare class Switch implements Component {
100
+ private pick;
101
+ constructor(pick: () => Component);
102
+ invalidate(): void;
103
+ render(width: number): string[];
104
+ }
105
+ export interface TreePanelView {
106
+ title: string;
107
+ rows: Array<{
108
+ node: PanelNode;
109
+ depth: number;
110
+ hasChildren: boolean;
111
+ collapsed: boolean;
112
+ }>;
113
+ total: PanelNode[];
114
+ focused: boolean;
115
+ selected?: string;
116
+ confirm?: {
117
+ id: string;
118
+ count: number;
119
+ };
120
+ }
121
+ /** Collapsible tree panel under the editor (generic; fed by plugin panel providers). */
122
+ export declare class TreePanel implements Component {
123
+ private view;
124
+ constructor(view: () => TreePanelView | undefined);
125
+ invalidate(): void;
126
+ render(width: number): string[];
127
+ }
128
+ /**
129
+ * Pending image attachments shown above the editor: an inline thumbnail (Kitty/iTerm2) when the
130
+ * terminal supports it, otherwise a compact one-line fallback. Empty when there are none.
131
+ */
132
+ export declare class AttachmentsBar implements Component {
133
+ private items;
134
+ private thumbnails;
135
+ constructor(items: () => PendingAttachment[]);
136
+ invalidate(): void;
137
+ render(width: number): string[];
138
+ }
139
+ /**
140
+ * Interactive multiple-choice question panel (`ask_user_question` / `/ask`). Shows one question at
141
+ * a time (stepped), for legibility in narrow terminals — a single scrollable panel holding every
142
+ * question's options at once would overflow or force heavy truncation below ~60 columns, whereas a
143
+ * step keeps each question fully readable and reflows independently on resize.
144
+ *
145
+ * Keys: ↑↓ move (wraps), →/Space toggle a multi-select option, Enter confirms the current question
146
+ * and advances (submits on the last one), ←/Backspace goes back to change an earlier answer (only
147
+ * offered when there is one), Esc skips the CURRENT question only (marks it undefined) and still
148
+ * advances — it never aborts the whole batch, so an earlier or later question is unaffected.
149
+ */
150
+ export declare class QuestionPanel implements Component {
151
+ private onSubmit;
152
+ private label?;
153
+ private state;
154
+ constructor(questions: QuestionSpec[], onSubmit: (answers: QuestionPanelState["answers"]) => void, label?: string | undefined);
155
+ invalidate(): void;
156
+ private dispatch;
157
+ handleInput(data: string): void;
158
+ render(width: number): string[];
159
+ }