@alisio/alisio-code 0.1.0-alpha.3
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/LICENSE +21 -0
- package/README.md +23 -0
- package/dist/banner.d.ts +56 -0
- package/dist/banner.js +69 -0
- package/dist/builtin.d.ts +8 -0
- package/dist/builtin.js +49 -0
- package/dist/main.d.ts +2 -0
- package/dist/main.js +476 -0
- package/dist/prompts/index.d.ts +5 -0
- package/dist/prompts/index.js +3 -0
- package/dist/prompts/init.d.ts +2 -0
- package/dist/prompts/init.js +57 -0
- package/dist/tui/app.d.ts +8 -0
- package/dist/tui/app.js +1313 -0
- package/dist/tui/attachments.d.ts +68 -0
- package/dist/tui/attachments.js +132 -0
- package/dist/tui/clipboard.d.ts +30 -0
- package/dist/tui/clipboard.js +57 -0
- package/dist/tui/components.d.ts +159 -0
- package/dist/tui/components.js +487 -0
- package/dist/tui/connect-input.d.ts +35 -0
- package/dist/tui/connect-input.js +104 -0
- package/dist/tui/panel.d.ts +54 -0
- package/dist/tui/panel.js +140 -0
- package/dist/tui/questions.d.ts +56 -0
- package/dist/tui/questions.js +113 -0
- package/dist/tui/queue.d.ts +35 -0
- package/dist/tui/queue.js +79 -0
- package/dist/tui/skills-manager.d.ts +67 -0
- package/dist/tui/skills-manager.js +200 -0
- package/dist/tui/state.d.ts +189 -0
- package/dist/tui/state.js +546 -0
- package/dist/tui/theme.d.ts +23 -0
- package/dist/tui/theme.js +49 -0
- package/package.json +66 -0
|
@@ -0,0 +1,487 @@
|
|
|
1
|
+
import { Container, getCapabilities, Image, Key, Markdown, matchesKey, truncateToWidth, visibleWidth, wrapTextWithAnsi, } from "@earendil-works/pi-tui";
|
|
2
|
+
import { attachmentCaption, MAX_ATTACHMENTS_PER_MESSAGE, } from "./attachments.js";
|
|
3
|
+
import { initialQuestionState, reduceQuestions, } from "./questions.js";
|
|
4
|
+
import { contextLevel, contextPercent, editSummary, fitSegments, formatContext, formatDuration, formatTokens, } from "./state.js";
|
|
5
|
+
import { imageTheme, levelColor, markdownTheme, style } from "./theme.js";
|
|
6
|
+
export const SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
7
|
+
/** Shared animation clock advanced by the app while work is running. */
|
|
8
|
+
export const clock = { frame: 0, now: Date.now() };
|
|
9
|
+
const fit = (lines, width) => lines.map((l) => truncateToWidth(l, width));
|
|
10
|
+
const wrap = (text, width) => text.split("\n").flatMap((line) => (line ? wrapTextWithAnsi(line, Math.max(1, width)) : [""]));
|
|
11
|
+
const permission = (label, state, priority) => ({
|
|
12
|
+
text: `${label}:${state}`,
|
|
13
|
+
priority,
|
|
14
|
+
paint: state === "on" ? style.green : state === "ask" ? style.yellow : style.gray,
|
|
15
|
+
});
|
|
16
|
+
/** Joins painted segments after dropping low-priority ones that do not fit the width. */
|
|
17
|
+
const line = (segments, width, separator = " · ") => fitSegments(segments, width, separator)
|
|
18
|
+
.map((s) => s.paint(s.text))
|
|
19
|
+
.join(style.gray(separator));
|
|
20
|
+
export class Header {
|
|
21
|
+
info;
|
|
22
|
+
view;
|
|
23
|
+
constructor(info, view) {
|
|
24
|
+
this.info = info;
|
|
25
|
+
this.view = view;
|
|
26
|
+
}
|
|
27
|
+
invalidate() { }
|
|
28
|
+
render(width) {
|
|
29
|
+
const i = this.info(), v = this.view();
|
|
30
|
+
const line1 = line([
|
|
31
|
+
{ text: "◆ alisio", priority: 8, paint: (t) => style.bold(style.brightCyan(t)) },
|
|
32
|
+
{ text: `v${i.version}`, priority: 1, paint: style.gray },
|
|
33
|
+
{
|
|
34
|
+
text: v.model || "not connected",
|
|
35
|
+
priority: 10,
|
|
36
|
+
paint: v.model ? style.bold : style.yellow,
|
|
37
|
+
},
|
|
38
|
+
...(i.provider ? [{ text: i.provider, priority: 6, paint: style.cyan }] : []),
|
|
39
|
+
{ text: i.host, priority: 5, paint: style.gray },
|
|
40
|
+
{ text: i.apiMode, priority: 2, paint: style.gray },
|
|
41
|
+
], width);
|
|
42
|
+
const perms = i.readOnly
|
|
43
|
+
? [{ text: "read-only", priority: 10, paint: style.red }]
|
|
44
|
+
: [
|
|
45
|
+
permission("write", i.write, 10),
|
|
46
|
+
permission("process", i.process, 10),
|
|
47
|
+
{
|
|
48
|
+
text: i.mcp ? "mcp:on" : "mcp:off",
|
|
49
|
+
priority: 7,
|
|
50
|
+
paint: i.mcp ? style.green : style.gray,
|
|
51
|
+
},
|
|
52
|
+
];
|
|
53
|
+
const line2 = line([
|
|
54
|
+
{ text: i.cwd, priority: 6, paint: style.cyan },
|
|
55
|
+
{ text: `session ${i.session}`, priority: 4, paint: style.gray },
|
|
56
|
+
...perms,
|
|
57
|
+
], width);
|
|
58
|
+
return fit([line1, line2, style.gray("─".repeat(Math.max(0, width)))], width);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
export class Footer {
|
|
62
|
+
view;
|
|
63
|
+
window;
|
|
64
|
+
hint;
|
|
65
|
+
statuses;
|
|
66
|
+
constructor(view, window, hint, statuses = () => []) {
|
|
67
|
+
this.view = view;
|
|
68
|
+
this.window = window;
|
|
69
|
+
this.hint = hint;
|
|
70
|
+
this.statuses = statuses;
|
|
71
|
+
}
|
|
72
|
+
invalidate() { }
|
|
73
|
+
render(width) {
|
|
74
|
+
const v = this.view(), total = this.window(), used = v.context?.used ?? 0, estimated = v.context?.estimated ?? true;
|
|
75
|
+
const pct = contextPercent(used, total);
|
|
76
|
+
const level = contextLevel(pct ?? 0);
|
|
77
|
+
const cells = 10, filled = pct === undefined ? 0 : Math.min(cells, Math.round((pct / 100) * cells));
|
|
78
|
+
const bar = pct === undefined
|
|
79
|
+
? style.gray("░".repeat(cells))
|
|
80
|
+
: levelColor(level)("█".repeat(filled)) + style.gray("░".repeat(cells - filled));
|
|
81
|
+
const state = v.compacting
|
|
82
|
+
? `${SPINNER[clock.frame % SPINNER.length]} compacting`
|
|
83
|
+
: v.streaming
|
|
84
|
+
? `${SPINNER[clock.frame % SPINNER.length]} streaming ${formatDuration(clock.now - (v.runStartedAt ?? clock.now))}`
|
|
85
|
+
: "● idle";
|
|
86
|
+
const s = v.stats;
|
|
87
|
+
const segments = [
|
|
88
|
+
{
|
|
89
|
+
text: `ctx ${formatContext(used, total, estimated)}`,
|
|
90
|
+
priority: 10,
|
|
91
|
+
paint: pct === undefined ? style.gray : levelColor(level),
|
|
92
|
+
},
|
|
93
|
+
{ text: state, priority: 9, paint: v.streaming || v.compacting ? style.cyan : style.green },
|
|
94
|
+
{
|
|
95
|
+
text: `↑${formatTokens(s.input)} ↓${formatTokens(s.output)}${s.cached ? ` ⚡${formatTokens(s.cached)}` : ""}`,
|
|
96
|
+
priority: 6,
|
|
97
|
+
paint: style.gray,
|
|
98
|
+
},
|
|
99
|
+
{ text: `turns ${s.turns}`, priority: 3, paint: style.gray },
|
|
100
|
+
...this.statuses().map((text) => ({ text, priority: 4, paint: style.magenta })),
|
|
101
|
+
...(s.lastRunMs !== undefined && !v.streaming
|
|
102
|
+
? [{ text: `last ${formatDuration(s.lastRunMs)}`, priority: 2, paint: style.gray }]
|
|
103
|
+
: []),
|
|
104
|
+
];
|
|
105
|
+
const barWidth = cells + 1;
|
|
106
|
+
const kept = fitSegments(segments, Math.max(1, width - barWidth), " · ");
|
|
107
|
+
const line1 = `${bar} ${kept.map((k) => k.paint(k.text)).join(style.gray(" · "))}`;
|
|
108
|
+
const hint = this.hint() ??
|
|
109
|
+
(v.streaming || v.compacting
|
|
110
|
+
? "Esc interrupt · Ctrl+C clear/exit"
|
|
111
|
+
: "Enter send · Shift+Enter newline · /help commands · Ctrl+D exit");
|
|
112
|
+
return fit([line1, style.dim(hint)], width);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
export class UserBlock {
|
|
116
|
+
text;
|
|
117
|
+
constructor(text) {
|
|
118
|
+
this.text = text;
|
|
119
|
+
}
|
|
120
|
+
invalidate() { }
|
|
121
|
+
render(width) {
|
|
122
|
+
const body = wrap(this.text, Math.max(1, width - 2));
|
|
123
|
+
return [
|
|
124
|
+
"",
|
|
125
|
+
...fit(body.map((line, i) => {
|
|
126
|
+
const content = `${i === 0 ? style.bold(style.magenta("❯ ")) : " "}${line}`;
|
|
127
|
+
const pad = Math.max(0, width - visibleWidth(content));
|
|
128
|
+
return style.userBg(`${content}${" ".repeat(pad)}`);
|
|
129
|
+
}), width),
|
|
130
|
+
];
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
export class AssistantBlock {
|
|
134
|
+
markdown = new Markdown("", 0, 0, markdownTheme);
|
|
135
|
+
item;
|
|
136
|
+
constructor(item) {
|
|
137
|
+
this.item = item;
|
|
138
|
+
this.markdown.setText(item.text);
|
|
139
|
+
}
|
|
140
|
+
update(item) {
|
|
141
|
+
if (item.text !== this.item.text)
|
|
142
|
+
this.markdown.setText(item.text);
|
|
143
|
+
this.item = item;
|
|
144
|
+
}
|
|
145
|
+
invalidate() {
|
|
146
|
+
this.markdown.invalidate();
|
|
147
|
+
}
|
|
148
|
+
render(width) {
|
|
149
|
+
const lines = [""];
|
|
150
|
+
const reasoning = this.item.reasoning.trim();
|
|
151
|
+
if (reasoning) {
|
|
152
|
+
if (this.item.done || this.item.text) {
|
|
153
|
+
const words = reasoning.split(/\s+/).length;
|
|
154
|
+
lines.push(style.dim(style.italic(`✻ reasoning (${words} words)`)));
|
|
155
|
+
}
|
|
156
|
+
else {
|
|
157
|
+
lines.push(style.dim(style.italic("✻ thinking…")));
|
|
158
|
+
lines.push(...wrap(reasoning, width - 2)
|
|
159
|
+
.slice(-4)
|
|
160
|
+
.map((l) => style.dim(` ${l}`)));
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
if (this.item.text)
|
|
164
|
+
lines.push(...this.markdown.render(width));
|
|
165
|
+
return fit(lines, width);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
export class ToolBlock {
|
|
169
|
+
item;
|
|
170
|
+
constructor(item) {
|
|
171
|
+
this.item = item;
|
|
172
|
+
}
|
|
173
|
+
invalidate() { }
|
|
174
|
+
render(width) {
|
|
175
|
+
const i = this.item;
|
|
176
|
+
const icon = i.status === "running"
|
|
177
|
+
? style.cyan(SPINNER[clock.frame % SPINNER.length] ?? "…")
|
|
178
|
+
: i.status === "approval"
|
|
179
|
+
? style.yellow("?")
|
|
180
|
+
: i.status === "ok"
|
|
181
|
+
? style.green("✓")
|
|
182
|
+
: style.red("✗");
|
|
183
|
+
const right = i.status === "approval"
|
|
184
|
+
? style.yellow("awaiting approval")
|
|
185
|
+
: i.durationMs !== undefined
|
|
186
|
+
? style.gray(formatDuration(i.durationMs))
|
|
187
|
+
: "";
|
|
188
|
+
const head = `${icon} ${style.bold(i.name)} ${style.gray(i.summary)}`;
|
|
189
|
+
const gap = width - visibleWidth(head) - visibleWidth(right) - 1;
|
|
190
|
+
const lines = [
|
|
191
|
+
gap > 0 ? `${head}${" ".repeat(gap + 1)}${right}` : truncateToWidth(head, width),
|
|
192
|
+
];
|
|
193
|
+
const diff = editSummary(i.name, i.args);
|
|
194
|
+
if (diff) {
|
|
195
|
+
lines.push(` ${style.gray("⎿")} ${style.green(`+${diff.added}`)} ${style.red(`-${diff.removed}`)} ${style.gray(diff.path)}`);
|
|
196
|
+
const max = 12;
|
|
197
|
+
for (const l of diff.lines.slice(0, max))
|
|
198
|
+
lines.push(` ${l.sign === "+" ? style.green(`+ ${l.text}`) : style.red(`- ${l.text}`)}`);
|
|
199
|
+
if (diff.lines.length > max)
|
|
200
|
+
lines.push(style.gray(` … ${diff.lines.length - max} more lines`));
|
|
201
|
+
}
|
|
202
|
+
if (i.preview && (i.status === "error" || !diff)) {
|
|
203
|
+
const preview = i.preview.split("\n").filter((l) => l.trim());
|
|
204
|
+
const shown = preview.slice(0, i.status === "error" ? 6 : 3);
|
|
205
|
+
const paint = i.status === "error" ? style.red : style.gray;
|
|
206
|
+
shown.forEach((l, n) => {
|
|
207
|
+
lines.push(` ${style.gray(n === 0 ? "⎿" : " ")} ${paint(l.replace(/\t/g, " "))}`);
|
|
208
|
+
});
|
|
209
|
+
if (preview.length > shown.length)
|
|
210
|
+
lines.push(style.gray(` … ${preview.length - shown.length} more lines`));
|
|
211
|
+
}
|
|
212
|
+
return fit(lines, width);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
export class LineBlock {
|
|
216
|
+
text;
|
|
217
|
+
kind;
|
|
218
|
+
constructor(text, kind) {
|
|
219
|
+
this.text = text;
|
|
220
|
+
this.kind = kind;
|
|
221
|
+
}
|
|
222
|
+
invalidate() { }
|
|
223
|
+
render(width) {
|
|
224
|
+
const prefix = this.kind === "error" ? style.red("✗ ") : style.gray("• ");
|
|
225
|
+
const paint = this.kind === "error" ? style.red : (t) => style.dim(style.italic(t));
|
|
226
|
+
return fit(wrap(this.text, Math.max(1, width - 2)).map((l, i) => `${i === 0 ? prefix : " "}${paint(l)}`), width);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
export class InfoBlock {
|
|
230
|
+
markdown;
|
|
231
|
+
constructor(text) {
|
|
232
|
+
this.markdown = new Markdown(text, 1, 0, markdownTheme);
|
|
233
|
+
}
|
|
234
|
+
invalidate() {
|
|
235
|
+
this.markdown.invalidate();
|
|
236
|
+
}
|
|
237
|
+
render(width) {
|
|
238
|
+
const inner = this.markdown.render(Math.max(1, width - 2));
|
|
239
|
+
return fit(["", ...inner.map((l) => `${style.gray("│")} ${l}`)], width);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
export function componentFor(item) {
|
|
243
|
+
switch (item.kind) {
|
|
244
|
+
case "user":
|
|
245
|
+
return new UserBlock(item.text);
|
|
246
|
+
case "assistant":
|
|
247
|
+
return new AssistantBlock(item);
|
|
248
|
+
case "tool":
|
|
249
|
+
return new ToolBlock(item);
|
|
250
|
+
case "info":
|
|
251
|
+
return new InfoBlock(item.text);
|
|
252
|
+
default:
|
|
253
|
+
return new LineBlock(item.text, item.kind);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
/** Startup screen rendered by core for the current width (cached until the width changes). */
|
|
257
|
+
export class BannerBlock {
|
|
258
|
+
renderLines;
|
|
259
|
+
cache;
|
|
260
|
+
constructor(renderLines) {
|
|
261
|
+
this.renderLines = renderLines;
|
|
262
|
+
}
|
|
263
|
+
invalidate() {
|
|
264
|
+
this.cache = undefined;
|
|
265
|
+
}
|
|
266
|
+
render(width) {
|
|
267
|
+
if (this.cache?.width !== width)
|
|
268
|
+
this.cache = { width, lines: [...this.renderLines(width), ""] };
|
|
269
|
+
return fit(this.cache.lines, width);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
/** Keeps a container of transcript components in step with view-model items. */
|
|
273
|
+
export class TranscriptSync {
|
|
274
|
+
container = new Container();
|
|
275
|
+
rendered = [];
|
|
276
|
+
sync(items) {
|
|
277
|
+
if (items.length < this.rendered.length)
|
|
278
|
+
this.reset();
|
|
279
|
+
items.forEach((item, index) => {
|
|
280
|
+
const entry = this.rendered[index];
|
|
281
|
+
if (!entry) {
|
|
282
|
+
const component = componentFor(item);
|
|
283
|
+
this.container.addChild(component);
|
|
284
|
+
this.rendered.push({ item, component });
|
|
285
|
+
}
|
|
286
|
+
else if (entry.item !== item) {
|
|
287
|
+
if (entry.component instanceof AssistantBlock && item.kind === "assistant")
|
|
288
|
+
entry.component.update(item);
|
|
289
|
+
else if (entry.component instanceof ToolBlock && item.kind === "tool")
|
|
290
|
+
entry.component.item = item;
|
|
291
|
+
entry.item = item;
|
|
292
|
+
}
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
reset() {
|
|
296
|
+
this.container.clear();
|
|
297
|
+
this.rendered = [];
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
/** Renders one of several components (main conversation or a read-only child view). */
|
|
301
|
+
export class Switch {
|
|
302
|
+
pick;
|
|
303
|
+
constructor(pick) {
|
|
304
|
+
this.pick = pick;
|
|
305
|
+
}
|
|
306
|
+
invalidate() {
|
|
307
|
+
this.pick().invalidate();
|
|
308
|
+
}
|
|
309
|
+
render(width) {
|
|
310
|
+
return this.pick().render(width);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
const NAMED = {
|
|
314
|
+
red: style.red,
|
|
315
|
+
green: style.green,
|
|
316
|
+
yellow: style.yellow,
|
|
317
|
+
blue: style.blue,
|
|
318
|
+
magenta: style.magenta,
|
|
319
|
+
cyan: style.cyan,
|
|
320
|
+
gray: style.gray,
|
|
321
|
+
};
|
|
322
|
+
const STATUS_ICON = {
|
|
323
|
+
queued: ["◷", style.gray],
|
|
324
|
+
completed: ["✓", style.green],
|
|
325
|
+
failed: ["✗", style.red],
|
|
326
|
+
cancelled: ["⊘", style.yellow],
|
|
327
|
+
interrupted: ["⚠", style.yellow],
|
|
328
|
+
/** Display-only status: a session (root or subagent) is blocked on an `ask_user_question` or
|
|
329
|
+
* approval prompt. Never persisted; overlaid by the display layer over "running" nodes. */
|
|
330
|
+
waiting: ["◆", style.magenta],
|
|
331
|
+
};
|
|
332
|
+
/** Collapsible tree panel under the editor (generic; fed by plugin panel providers). */
|
|
333
|
+
export class TreePanel {
|
|
334
|
+
view;
|
|
335
|
+
constructor(view) {
|
|
336
|
+
this.view = view;
|
|
337
|
+
}
|
|
338
|
+
invalidate() { }
|
|
339
|
+
render(width) {
|
|
340
|
+
const v = this.view();
|
|
341
|
+
if (!v || !v.total.length)
|
|
342
|
+
return [];
|
|
343
|
+
const count = (s) => v.total.filter((n) => n.status === s).length;
|
|
344
|
+
const running = count("running"), queued = count("queued"), waiting = count("waiting"), done = v.total.filter((n) => !["running", "queued", "waiting"].includes(n.status)).length;
|
|
345
|
+
const expanded = v.focused || running + queued + waiting > 0;
|
|
346
|
+
const head = `${expanded ? "▾" : "▸"} ${style.bold(v.title)} ${v.total.length} ${style.gray("·")} ${style.cyan(`▶ ${running} running`)}${waiting ? ` ${style.gray("·")} ${style.magenta(`◆ ${waiting} waiting`)}` : ""} ${style.gray("·")} ${style.gray(`◷ ${queued} queued`)} ${style.gray("·")} ${style.green(`✓ ${done} finished`)}${v.focused ? "" : style.dim(" (Ctrl+X to navigate)")}`;
|
|
347
|
+
const lines = [head];
|
|
348
|
+
if (expanded) {
|
|
349
|
+
const selectedIndex = Math.max(0, v.rows.findIndex((r) => r.node.id === v.selected));
|
|
350
|
+
const max = 8;
|
|
351
|
+
const start = Math.max(0, Math.min(selectedIndex - Math.floor(max / 2), v.rows.length - max));
|
|
352
|
+
for (const row of v.rows.slice(start, start + max)) {
|
|
353
|
+
const n = row.node;
|
|
354
|
+
const [icon, paint] = n.status === "running"
|
|
355
|
+
? [SPINNER[clock.frame % SPINNER.length] ?? "…", style.cyan]
|
|
356
|
+
: (STATUS_ICON[n.status] ?? ["•", style.gray]);
|
|
357
|
+
const elapsed = n.startedAt ? formatDuration((n.endedAt ?? clock.now) - n.startedAt) : "";
|
|
358
|
+
const name = (NAMED[n.color ?? ""] ?? style.bold)(n.label);
|
|
359
|
+
const branch = row.hasChildren ? (row.collapsed ? "▸ " : "▾ ") : " ";
|
|
360
|
+
let line = `${" ".repeat(row.depth + 1)}${branch}${paint(icon)} ${name} ${style.gray([elapsed, n.tokens ? `${formatTokens(n.tokens)} tok` : ""].filter(Boolean).join(" · "))} ${style.dim(n.detail ?? "")}`;
|
|
361
|
+
line = truncateToWidth(line, width);
|
|
362
|
+
if (v.focused && n.id === v.selected)
|
|
363
|
+
line = `\x1b[7m${line}\x1b[27m`;
|
|
364
|
+
lines.push(line);
|
|
365
|
+
}
|
|
366
|
+
if (v.rows.length > max)
|
|
367
|
+
lines.push(style.gray(` … ${v.rows.length - max} more (↑↓ to scroll)`));
|
|
368
|
+
if (v.confirm)
|
|
369
|
+
lines.push(style.yellow(` Cancel this agent and ${v.confirm.count} descendant(s)? y/n`));
|
|
370
|
+
}
|
|
371
|
+
return fit(lines, width);
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
/**
|
|
375
|
+
* Pending image attachments shown above the editor: an inline thumbnail (Kitty/iTerm2) when the
|
|
376
|
+
* terminal supports it, otherwise a compact one-line fallback. Empty when there are none.
|
|
377
|
+
*/
|
|
378
|
+
export class AttachmentsBar {
|
|
379
|
+
items;
|
|
380
|
+
thumbnails = new Map();
|
|
381
|
+
constructor(items) {
|
|
382
|
+
this.items = items;
|
|
383
|
+
}
|
|
384
|
+
invalidate() {
|
|
385
|
+
for (const image of this.thumbnails.values())
|
|
386
|
+
image.invalidate();
|
|
387
|
+
}
|
|
388
|
+
render(width) {
|
|
389
|
+
const items = this.items();
|
|
390
|
+
if (!items.length)
|
|
391
|
+
return [];
|
|
392
|
+
const capable = !!getCapabilities().images;
|
|
393
|
+
const seen = new Set();
|
|
394
|
+
const lines = [];
|
|
395
|
+
items.forEach((a, i) => {
|
|
396
|
+
seen.add(a.id);
|
|
397
|
+
lines.push(style.gray(truncateToWidth(attachmentCaption(a, i), width)));
|
|
398
|
+
if (capable) {
|
|
399
|
+
let thumbnail = this.thumbnails.get(a.id);
|
|
400
|
+
if (!thumbnail) {
|
|
401
|
+
thumbnail = new Image(a.data, a.mimeType, imageTheme, {
|
|
402
|
+
maxWidthCells: Math.min(24, width),
|
|
403
|
+
maxHeightCells: 3,
|
|
404
|
+
});
|
|
405
|
+
this.thumbnails.set(a.id, thumbnail);
|
|
406
|
+
}
|
|
407
|
+
lines.push(...thumbnail.render(width));
|
|
408
|
+
}
|
|
409
|
+
});
|
|
410
|
+
for (const id of [...this.thumbnails.keys()])
|
|
411
|
+
if (!seen.has(id))
|
|
412
|
+
this.thumbnails.delete(id);
|
|
413
|
+
lines.push(style.dim(`Ctrl+V paste image · Ctrl+R remove last (${items.length}/${MAX_ATTACHMENTS_PER_MESSAGE})`));
|
|
414
|
+
return fit(lines, width);
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
/**
|
|
418
|
+
* Interactive multiple-choice question panel (`ask_user_question` / `/ask`). Shows one question at
|
|
419
|
+
* a time (stepped), for legibility in narrow terminals — a single scrollable panel holding every
|
|
420
|
+
* question's options at once would overflow or force heavy truncation below ~60 columns, whereas a
|
|
421
|
+
* step keeps each question fully readable and reflows independently on resize.
|
|
422
|
+
*
|
|
423
|
+
* Keys: ↑↓ move (wraps), →/Space toggle a multi-select option, Enter confirms the current question
|
|
424
|
+
* and advances (submits on the last one), ←/Backspace goes back to change an earlier answer (only
|
|
425
|
+
* offered when there is one), Esc skips the CURRENT question only (marks it undefined) and still
|
|
426
|
+
* advances — it never aborts the whole batch, so an earlier or later question is unaffected.
|
|
427
|
+
*/
|
|
428
|
+
export class QuestionPanel {
|
|
429
|
+
onSubmit;
|
|
430
|
+
label;
|
|
431
|
+
state;
|
|
432
|
+
constructor(questions, onSubmit, label) {
|
|
433
|
+
this.onSubmit = onSubmit;
|
|
434
|
+
this.label = label;
|
|
435
|
+
this.state = initialQuestionState(questions);
|
|
436
|
+
}
|
|
437
|
+
invalidate() { }
|
|
438
|
+
dispatch(action) {
|
|
439
|
+
const { state, effect } = reduceQuestions(this.state, action);
|
|
440
|
+
this.state = state;
|
|
441
|
+
if (effect)
|
|
442
|
+
this.onSubmit(effect.answers);
|
|
443
|
+
}
|
|
444
|
+
handleInput(data) {
|
|
445
|
+
if (matchesKey(data, Key.up))
|
|
446
|
+
return this.dispatch({ type: "up" });
|
|
447
|
+
if (matchesKey(data, Key.down))
|
|
448
|
+
return this.dispatch({ type: "down" });
|
|
449
|
+
if (matchesKey(data, Key.right) || matchesKey(data, Key.space))
|
|
450
|
+
return this.dispatch({ type: "toggle" });
|
|
451
|
+
if (matchesKey(data, Key.enter))
|
|
452
|
+
return this.dispatch({ type: "confirm" });
|
|
453
|
+
if (matchesKey(data, Key.left) || matchesKey(data, Key.backspace))
|
|
454
|
+
return this.dispatch({ type: "back" });
|
|
455
|
+
if (matchesKey(data, Key.escape))
|
|
456
|
+
return this.dispatch({ type: "skip" });
|
|
457
|
+
}
|
|
458
|
+
render(width) {
|
|
459
|
+
const s = this.state;
|
|
460
|
+
const spec = s.questions[s.index];
|
|
461
|
+
const lines = [];
|
|
462
|
+
const breadcrumb = this.label ? style.dim(`${this.label} asks:`) : undefined;
|
|
463
|
+
if (breadcrumb)
|
|
464
|
+
lines.push(truncateToWidth(breadcrumb, width));
|
|
465
|
+
lines.push(truncateToWidth(`${style.bold(style.yellow(spec.header))} ${style.dim(`Question ${s.index + 1}/${s.questions.length}`)}`, width));
|
|
466
|
+
lines.push(...wrap(spec.question, Math.max(1, width - 2)).map((l) => ` ${l}`));
|
|
467
|
+
spec.options.forEach((option, i) => {
|
|
468
|
+
const focused = i === s.cursor;
|
|
469
|
+
const cursor = focused ? style.cyan("❯ ") : " ";
|
|
470
|
+
const box = spec.multiSelect ? (s.toggled.has(i) ? "[x] " : "[ ] ") : "";
|
|
471
|
+
const label = focused ? style.bold(style.cyan(option.label)) : option.label;
|
|
472
|
+
const tag = option.recommended ? ` ${style.green("(recommended)")}` : "";
|
|
473
|
+
lines.push(truncateToWidth(`${cursor}${box}${label}${tag}`, width));
|
|
474
|
+
if (option.description)
|
|
475
|
+
lines.push(...wrap(option.description, Math.max(1, width - 4)).map((l) => ` ${style.gray(l)}`));
|
|
476
|
+
});
|
|
477
|
+
const hints = [
|
|
478
|
+
"↑↓ select",
|
|
479
|
+
...(spec.multiSelect ? ["→/Space toggle"] : []),
|
|
480
|
+
"Enter confirm",
|
|
481
|
+
...(s.index > 0 ? ["←/Backspace back"] : []),
|
|
482
|
+
"Esc skip",
|
|
483
|
+
];
|
|
484
|
+
lines.push(style.dim(` ${hints.join(" · ")}`));
|
|
485
|
+
return fit(lines, width);
|
|
486
|
+
}
|
|
487
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { type Component } from "@earendil-works/pi-tui";
|
|
2
|
+
export interface ConnectInputState {
|
|
3
|
+
value: string;
|
|
4
|
+
cursor: number;
|
|
5
|
+
}
|
|
6
|
+
export type ConnectInputAction = {
|
|
7
|
+
type: "insert";
|
|
8
|
+
text: string;
|
|
9
|
+
} | {
|
|
10
|
+
type: "left" | "right" | "home" | "end" | "backspace" | "delete";
|
|
11
|
+
};
|
|
12
|
+
/** Removes terminal paste framing and controls while retaining an entire printable input chunk. */
|
|
13
|
+
export declare function printableInput(data: string): string;
|
|
14
|
+
export declare function reduceConnectInput(state: ConnectInputState, action: ConnectInputAction): ConnectInputState;
|
|
15
|
+
/** Focused single-field dialog for provider configuration; secret values never leave this component. */
|
|
16
|
+
export declare class ConnectInputPrompt implements Component {
|
|
17
|
+
private options;
|
|
18
|
+
private state;
|
|
19
|
+
private replaceInitial;
|
|
20
|
+
constructor(options: {
|
|
21
|
+
provider: string;
|
|
22
|
+
label: string;
|
|
23
|
+
initial: string;
|
|
24
|
+
secret: boolean;
|
|
25
|
+
placeholder: string;
|
|
26
|
+
hint?: string;
|
|
27
|
+
step: number;
|
|
28
|
+
steps: number;
|
|
29
|
+
onSubmit: (value: string) => void;
|
|
30
|
+
onCancel: () => void;
|
|
31
|
+
});
|
|
32
|
+
invalidate(): void;
|
|
33
|
+
handleInput(data: string): void;
|
|
34
|
+
render(width: number): string[];
|
|
35
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { Key, matchesKey, truncateToWidth } from "@earendil-works/pi-tui";
|
|
2
|
+
import { style } from "./theme.js";
|
|
3
|
+
const characters = (value) => Array.from(value);
|
|
4
|
+
/** Removes terminal paste framing and controls while retaining an entire printable input chunk. */
|
|
5
|
+
export function printableInput(data) {
|
|
6
|
+
return data
|
|
7
|
+
.replaceAll("\x1b[200~", "")
|
|
8
|
+
.replaceAll("\x1b[201~", "")
|
|
9
|
+
.replace(/[\u0000-\u001f\u007f]/g, "");
|
|
10
|
+
}
|
|
11
|
+
export function reduceConnectInput(state, action) {
|
|
12
|
+
const value = characters(state.value);
|
|
13
|
+
const cursor = Math.max(0, Math.min(state.cursor, value.length));
|
|
14
|
+
if (action.type === "insert") {
|
|
15
|
+
const inserted = characters(action.text);
|
|
16
|
+
value.splice(cursor, 0, ...inserted);
|
|
17
|
+
return { value: value.join(""), cursor: cursor + inserted.length };
|
|
18
|
+
}
|
|
19
|
+
if (action.type === "left")
|
|
20
|
+
return { value: state.value, cursor: Math.max(0, cursor - 1) };
|
|
21
|
+
if (action.type === "right")
|
|
22
|
+
return { value: state.value, cursor: Math.min(value.length, cursor + 1) };
|
|
23
|
+
if (action.type === "home")
|
|
24
|
+
return { value: state.value, cursor: 0 };
|
|
25
|
+
if (action.type === "end")
|
|
26
|
+
return { value: state.value, cursor: value.length };
|
|
27
|
+
if (action.type === "backspace" && cursor > 0) {
|
|
28
|
+
value.splice(cursor - 1, 1);
|
|
29
|
+
return { value: value.join(""), cursor: cursor - 1 };
|
|
30
|
+
}
|
|
31
|
+
if (action.type === "delete" && cursor < value.length)
|
|
32
|
+
value.splice(cursor, 1);
|
|
33
|
+
return { value: value.join(""), cursor };
|
|
34
|
+
}
|
|
35
|
+
/** Focused single-field dialog for provider configuration; secret values never leave this component. */
|
|
36
|
+
export class ConnectInputPrompt {
|
|
37
|
+
options;
|
|
38
|
+
state;
|
|
39
|
+
replaceInitial;
|
|
40
|
+
constructor(options) {
|
|
41
|
+
this.options = options;
|
|
42
|
+
this.state = { value: options.initial, cursor: characters(options.initial).length };
|
|
43
|
+
this.replaceInitial = !!options.initial;
|
|
44
|
+
}
|
|
45
|
+
invalidate() { }
|
|
46
|
+
handleInput(data) {
|
|
47
|
+
if (matchesKey(data, Key.escape))
|
|
48
|
+
return this.options.onCancel();
|
|
49
|
+
if (matchesKey(data, Key.enter))
|
|
50
|
+
return this.options.onSubmit(this.state.value);
|
|
51
|
+
const action = matchesKey(data, Key.left)
|
|
52
|
+
? "left"
|
|
53
|
+
: matchesKey(data, Key.right)
|
|
54
|
+
? "right"
|
|
55
|
+
: matchesKey(data, Key.home)
|
|
56
|
+
? "home"
|
|
57
|
+
: matchesKey(data, Key.end)
|
|
58
|
+
? "end"
|
|
59
|
+
: matchesKey(data, Key.backspace)
|
|
60
|
+
? "backspace"
|
|
61
|
+
: matchesKey(data, Key.delete)
|
|
62
|
+
? "delete"
|
|
63
|
+
: undefined;
|
|
64
|
+
if (action) {
|
|
65
|
+
if (this.replaceInitial && (action === "backspace" || action === "delete")) {
|
|
66
|
+
this.state = { value: "", cursor: 0 };
|
|
67
|
+
this.replaceInitial = false;
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
this.replaceInitial = false;
|
|
71
|
+
this.state = reduceConnectInput(this.state, { type: action });
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
const text = printableInput(data);
|
|
75
|
+
if (text) {
|
|
76
|
+
if (this.replaceInitial)
|
|
77
|
+
this.state = { value: "", cursor: 0 };
|
|
78
|
+
this.replaceInitial = false;
|
|
79
|
+
this.state = reduceConnectInput(this.state, { type: "insert", text });
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
render(width) {
|
|
83
|
+
const value = characters(this.state.value);
|
|
84
|
+
const masked = this.options.secret ? value.map(() => "•") : value;
|
|
85
|
+
const before = masked.slice(0, this.state.cursor).join("");
|
|
86
|
+
const current = masked[this.state.cursor] ?? " ";
|
|
87
|
+
const after = masked.slice(this.state.cursor + 1).join("");
|
|
88
|
+
const empty = !value.length;
|
|
89
|
+
const field = empty
|
|
90
|
+
? `${style.dim(this.options.placeholder)} ${style.brightCyan("▏")}`
|
|
91
|
+
: `${before}${style.brightCyan(`▏${current}`)}${after}`;
|
|
92
|
+
const rule = "─".repeat(Math.max(1, Math.min(56, width - 2)));
|
|
93
|
+
return [
|
|
94
|
+
truncateToWidth(style.cyan(`┌${rule}┐`), width),
|
|
95
|
+
truncateToWidth(`│ ${style.bold(`Connect · ${this.options.provider}`)} ${style.dim(`(${this.options.step}/${this.options.steps})`)}`, width),
|
|
96
|
+
truncateToWidth(`│ ${style.brightCyan("❯")} ${style.bold(this.options.label)}`, width),
|
|
97
|
+
truncateToWidth(`│ ${field}`, width),
|
|
98
|
+
...(this.options.hint ? [truncateToWidth(`│ ${style.dim(this.options.hint)}`, width)] : []),
|
|
99
|
+
truncateToWidth(`│ ${style.dim(`${this.replaceInitial ? "Paste or type to replace · " : ""}←/→ Home/End edit`)}`, width),
|
|
100
|
+
truncateToWidth(`│ ${style.dim("Enter submit · Esc cancel")}`, width),
|
|
101
|
+
truncateToWidth(style.cyan(`└${rule}┘`), width),
|
|
102
|
+
];
|
|
103
|
+
}
|
|
104
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure focus/navigation reducer for plugin tree panels (e.g. the agent tree) and the read-only
|
|
3
|
+
* child session view. Three focus modes:
|
|
4
|
+
* - editor: normal typing; Ctrl+X, or ↓ on an empty editor, focuses the panel.
|
|
5
|
+
* - panel: plain arrows move/expand/collapse, Enter opens, Esc/Tab return to the editor.
|
|
6
|
+
* Ctrl+X then ↓ within the chord window opens the first agent directly.
|
|
7
|
+
* - view: a child's read-only conversation; ↑ parent, ↓ first child, ←/→ siblings, Esc back.
|
|
8
|
+
* Ctrl+K cancels the selected/viewed node (confirmation when it has descendants).
|
|
9
|
+
*/
|
|
10
|
+
import type { PanelNode } from "@alisio/sdk";
|
|
11
|
+
export interface PanelState {
|
|
12
|
+
focus: "editor" | "panel" | "view";
|
|
13
|
+
selected?: string;
|
|
14
|
+
viewing?: string;
|
|
15
|
+
collapsed: Set<string>;
|
|
16
|
+
chordUntil?: number;
|
|
17
|
+
confirm?: {
|
|
18
|
+
id: string;
|
|
19
|
+
count: number;
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
export type PanelAction = {
|
|
23
|
+
type: "ctrlX";
|
|
24
|
+
now: number;
|
|
25
|
+
} | {
|
|
26
|
+
type: "key";
|
|
27
|
+
key: string;
|
|
28
|
+
now: number;
|
|
29
|
+
editorEmpty?: boolean;
|
|
30
|
+
};
|
|
31
|
+
export type PanelEffect = {
|
|
32
|
+
type: "open";
|
|
33
|
+
sessionId: string;
|
|
34
|
+
} | {
|
|
35
|
+
type: "close-view";
|
|
36
|
+
} | {
|
|
37
|
+
type: "cancel";
|
|
38
|
+
id: string;
|
|
39
|
+
};
|
|
40
|
+
export declare const CHORD_MS = 800;
|
|
41
|
+
export declare const initialPanelState: () => PanelState;
|
|
42
|
+
export interface Row {
|
|
43
|
+
node: PanelNode;
|
|
44
|
+
depth: number;
|
|
45
|
+
hasChildren: boolean;
|
|
46
|
+
}
|
|
47
|
+
/** Tree order (parents before children) skipping the descendants of collapsed nodes. */
|
|
48
|
+
export declare function visibleRows(nodes: PanelNode[], collapsed: Set<string>): Row[];
|
|
49
|
+
export declare function descendants(nodes: PanelNode[], id: string): string[];
|
|
50
|
+
export declare function reducePanel(state: PanelState, action: PanelAction, nodes: PanelNode[]): {
|
|
51
|
+
state: PanelState;
|
|
52
|
+
effect?: PanelEffect;
|
|
53
|
+
handled: boolean;
|
|
54
|
+
};
|