@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.
@@ -0,0 +1,140 @@
1
+ export const CHORD_MS = 800;
2
+ export const initialPanelState = () => ({ focus: "editor", collapsed: new Set() });
3
+ const childrenOf = (nodes, id) => nodes.filter((n) => (n.parentId ?? undefined) === id);
4
+ /** Tree order (parents before children) skipping the descendants of collapsed nodes. */
5
+ export function visibleRows(nodes, collapsed) {
6
+ const rows = [];
7
+ const known = new Set(nodes.map((n) => n.id));
8
+ const visit = (parent, depth) => {
9
+ for (const node of nodes.filter((n) => parent === undefined ? !n.parentId || !known.has(n.parentId) : n.parentId === parent)) {
10
+ const kids = childrenOf(nodes, node.id);
11
+ rows.push({ node, depth, hasChildren: kids.length > 0 });
12
+ if (!collapsed.has(node.id))
13
+ visit(node.id, depth + 1);
14
+ }
15
+ };
16
+ visit(undefined, 0);
17
+ return rows;
18
+ }
19
+ export function descendants(nodes, id) {
20
+ return childrenOf(nodes, id).flatMap((c) => [c.id, ...descendants(nodes, c.id)]);
21
+ }
22
+ const byId = (nodes, id) => nodes.find((n) => n.id === id);
23
+ export function reducePanel(state, action, nodes) {
24
+ const done = (next, effect) => ({
25
+ state: next,
26
+ ...(effect ? { effect } : {}),
27
+ handled: true,
28
+ });
29
+ const skip = { state, handled: false };
30
+ const first = visibleRows(nodes, state.collapsed)[0]?.node;
31
+ const cancelOrConfirm = (id) => {
32
+ if (!id)
33
+ return done(state);
34
+ const count = descendants(nodes, id).length;
35
+ return count
36
+ ? done({ ...state, confirm: { id, count } })
37
+ : done({ ...state, confirm: undefined }, { type: "cancel", id });
38
+ };
39
+ const open = (node, base) => node?.sessionId
40
+ ? done({ ...base, focus: "view", viewing: node.id, selected: node.id, chordUntil: undefined }, {
41
+ type: "open",
42
+ sessionId: node.sessionId,
43
+ })
44
+ : done(base);
45
+ if (state.confirm && action.type === "key") {
46
+ if (action.key === "yes")
47
+ return done({ ...state, confirm: undefined }, { type: "cancel", id: state.confirm.id });
48
+ return done({ ...state, confirm: undefined });
49
+ }
50
+ if (action.type === "ctrlX") {
51
+ if (!nodes.length)
52
+ return skip;
53
+ const running = nodes.find((n) => n.status === "running") ?? first;
54
+ return done({
55
+ ...state,
56
+ focus: "panel",
57
+ selected: state.selected && byId(nodes, state.selected) ? state.selected : (running?.id ?? first?.id),
58
+ chordUntil: action.now + CHORD_MS,
59
+ });
60
+ }
61
+ const key = action.key;
62
+ if (state.focus === "editor") {
63
+ if (key === "down" && action.editorEmpty && nodes.length)
64
+ return done({ ...state, focus: "panel", selected: state.selected ?? first?.id });
65
+ return skip;
66
+ }
67
+ if (state.focus === "panel") {
68
+ const rows = visibleRows(nodes, state.collapsed);
69
+ const index = Math.max(0, rows.findIndex((r) => r.node.id === state.selected));
70
+ const current = rows[index];
71
+ switch (key) {
72
+ case "down":
73
+ if (state.chordUntil && action.now <= state.chordUntil)
74
+ return open(first, state);
75
+ return done({
76
+ ...state,
77
+ selected: rows[Math.min(rows.length - 1, index + 1)]?.node.id,
78
+ chordUntil: undefined,
79
+ });
80
+ case "up":
81
+ return done({
82
+ ...state,
83
+ selected: rows[Math.max(0, index - 1)]?.node.id,
84
+ chordUntil: undefined,
85
+ });
86
+ case "right": {
87
+ if (!current?.hasChildren)
88
+ return done(state);
89
+ if (state.collapsed.has(current.node.id)) {
90
+ const collapsed = new Set(state.collapsed);
91
+ collapsed.delete(current.node.id);
92
+ return done({ ...state, collapsed });
93
+ }
94
+ return done({ ...state, selected: childrenOf(nodes, current.node.id)[0]?.id });
95
+ }
96
+ case "left": {
97
+ if (current?.hasChildren && !state.collapsed.has(current.node.id))
98
+ return done({ ...state, collapsed: new Set([...state.collapsed, current.node.id]) });
99
+ const parent = current?.node.parentId;
100
+ return done(parent && byId(nodes, parent) ? { ...state, selected: parent } : state);
101
+ }
102
+ case "enter":
103
+ return open(current?.node, state);
104
+ case "escape":
105
+ case "tab":
106
+ return done({ ...state, focus: "editor", chordUntil: undefined });
107
+ case "cancel":
108
+ return cancelOrConfirm(current?.node.id);
109
+ default:
110
+ // Any other key returns focus to the editor and is typed there.
111
+ return { state: { ...state, focus: "editor", chordUntil: undefined }, handled: false };
112
+ }
113
+ }
114
+ // Read-only child view.
115
+ const viewing = byId(nodes, state.viewing);
116
+ const back = () => done({ ...state, focus: "editor", viewing: undefined }, { type: "close-view" });
117
+ switch (key) {
118
+ case "escape":
119
+ return back();
120
+ case "up": {
121
+ const parent = byId(nodes, viewing?.parentId);
122
+ return parent ? open(parent, state) : back();
123
+ }
124
+ case "down":
125
+ return open(childrenOf(nodes, viewing?.id)[0], state);
126
+ case "left":
127
+ case "right": {
128
+ const siblings = childrenOf(nodes, viewing?.parentId);
129
+ const i = siblings.findIndex((s) => s.id === viewing?.id);
130
+ if (siblings.length < 2 || i < 0)
131
+ return done(state);
132
+ const next = siblings[(i + (key === "right" ? 1 : siblings.length - 1)) % siblings.length];
133
+ return open(next, state);
134
+ }
135
+ case "cancel":
136
+ return cancelOrConfirm(viewing?.id);
137
+ default:
138
+ return done(state);
139
+ }
140
+ }
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Pure, pi-tui-free reducer for the interactive question panel (`ask_user_question` / `/ask`).
3
+ * Mirrors the `panel.ts` convention: state+action -> {state, effect}, fully unit-testable without
4
+ * a TTY. Navigation wraps around (matching pi-tui's `SelectList`), never clamps. The question
5
+ * specs are carried inside the state itself (set once by `initialQuestionState`) so the reducer
6
+ * and the rendering component both stay self-contained from `state` alone.
7
+ */
8
+ export interface QuestionOptionSpec {
9
+ label: string;
10
+ description?: string;
11
+ recommended?: boolean;
12
+ }
13
+ export interface QuestionSpec {
14
+ header: string;
15
+ question: string;
16
+ multiSelect?: boolean;
17
+ options: QuestionOptionSpec[];
18
+ }
19
+ /** One question's recorded answer: which option indices, and whether it was skipped (Esc). */
20
+ export interface QuestionAnswer {
21
+ skipped: boolean;
22
+ indices: number[];
23
+ }
24
+ export interface QuestionPanelState {
25
+ questions: QuestionSpec[];
26
+ index: number;
27
+ cursor: number;
28
+ done: boolean;
29
+ answers: Array<QuestionAnswer | undefined>;
30
+ toggled: Set<number>;
31
+ }
32
+ export type QuestionAction = {
33
+ type: "up";
34
+ } | {
35
+ type: "down";
36
+ } | {
37
+ type: "toggle";
38
+ } | {
39
+ type: "confirm";
40
+ } | {
41
+ type: "back";
42
+ } | {
43
+ type: "skip";
44
+ };
45
+ export interface QuestionSubmitEffect {
46
+ type: "submit";
47
+ answers: Array<QuestionAnswer | undefined>;
48
+ }
49
+ export interface ReduceResult {
50
+ state: QuestionPanelState;
51
+ effect?: QuestionSubmitEffect;
52
+ }
53
+ export declare function initialQuestionState(questions: QuestionSpec[]): QuestionPanelState;
54
+ export declare function reduceQuestions(state: QuestionPanelState, action: QuestionAction): ReduceResult;
55
+ export declare function truncateLabel(text: string, max?: number): string;
56
+ export declare function summarizeAnswers(questions: QuestionSpec[], answers: Array<QuestionAnswer | undefined>): string;
@@ -0,0 +1,113 @@
1
+ /**
2
+ * Pure, pi-tui-free reducer for the interactive question panel (`ask_user_question` / `/ask`).
3
+ * Mirrors the `panel.ts` convention: state+action -> {state, effect}, fully unit-testable without
4
+ * a TTY. Navigation wraps around (matching pi-tui's `SelectList`), never clamps. The question
5
+ * specs are carried inside the state itself (set once by `initialQuestionState`) so the reducer
6
+ * and the rendering component both stay self-contained from `state` alone.
7
+ */
8
+ const recommendedIndex = (spec) => {
9
+ const i = spec.options.findIndex((o) => o.recommended);
10
+ return i >= 0 ? i : 0;
11
+ };
12
+ /**
13
+ * Cursor/toggled to show when navigating (back/forward) into a question, restoring any prior
14
+ * answer. A question with no prior answer starts with its recommended (or first) option already
15
+ * highlighted; for a multi-select question that also means pre-toggled, so confirming without
16
+ * touching anything records a deliberate choice rather than an accidental empty one. A skipped
17
+ * prior answer restores to that same untouched default rather than remembering the empty choice.
18
+ * `initialQuestionState` (the very first question of a fresh batch) intentionally does not
19
+ * pre-toggle, so an explicit empty multi-select confirm stays distinguishable from skip there too.
20
+ */
21
+ const restore = (spec, prior) => {
22
+ if (!prior || prior.skipped)
23
+ return {
24
+ cursor: recommendedIndex(spec),
25
+ toggled: new Set(prior ? [] : [recommendedIndex(spec)]),
26
+ };
27
+ return { cursor: prior.indices[0] ?? recommendedIndex(spec), toggled: new Set(prior.indices) };
28
+ };
29
+ export function initialQuestionState(questions) {
30
+ const first = questions[0];
31
+ return {
32
+ questions,
33
+ index: 0,
34
+ cursor: first ? recommendedIndex(first) : 0,
35
+ done: false,
36
+ answers: questions.map(() => undefined),
37
+ toggled: new Set(),
38
+ };
39
+ }
40
+ function advance(state, answer) {
41
+ const answers = state.answers.slice();
42
+ answers[state.index] = answer;
43
+ const nextIndex = state.index + 1;
44
+ if (nextIndex >= state.questions.length) {
45
+ const done = { ...state, answers, done: true };
46
+ return { state: done, effect: { type: "submit", answers } };
47
+ }
48
+ const nextSpec = state.questions[nextIndex];
49
+ const { cursor, toggled } = restore(nextSpec, answers[nextIndex]);
50
+ return { state: { ...state, index: nextIndex, cursor, toggled, answers, done: false } };
51
+ }
52
+ export function reduceQuestions(state, action) {
53
+ if (state.done)
54
+ return { state };
55
+ const spec = state.questions[state.index];
56
+ switch (action.type) {
57
+ case "up": {
58
+ const cursor = (state.cursor - 1 + spec.options.length) % spec.options.length;
59
+ return { state: { ...state, cursor } };
60
+ }
61
+ case "down": {
62
+ const cursor = (state.cursor + 1) % spec.options.length;
63
+ return { state: { ...state, cursor } };
64
+ }
65
+ case "toggle": {
66
+ if (!spec.multiSelect)
67
+ return { state };
68
+ const toggled = new Set(state.toggled);
69
+ if (toggled.has(state.cursor))
70
+ toggled.delete(state.cursor);
71
+ else
72
+ toggled.add(state.cursor);
73
+ return { state: { ...state, toggled } };
74
+ }
75
+ case "confirm": {
76
+ const indices = spec.multiSelect ? [...state.toggled].sort((a, b) => a - b) : [state.cursor];
77
+ return advance(state, { skipped: false, indices });
78
+ }
79
+ case "skip":
80
+ return advance(state, { skipped: true, indices: [] });
81
+ case "back": {
82
+ if (state.index === 0)
83
+ return { state };
84
+ const prevIndex = state.index - 1;
85
+ const prevSpec = state.questions[prevIndex];
86
+ const { cursor, toggled } = restore(prevSpec, state.answers[prevIndex]);
87
+ return { state: { ...state, index: prevIndex, cursor, toggled, done: false } };
88
+ }
89
+ default:
90
+ return { state };
91
+ }
92
+ }
93
+ export function truncateLabel(text, max = 50) {
94
+ if (text.length <= max)
95
+ return text;
96
+ return `${text.slice(0, Math.max(0, max - 1))}…`;
97
+ }
98
+ export function summarizeAnswers(questions, answers) {
99
+ const lines = questions.map((spec, index) => {
100
+ const answer = answers[index];
101
+ if (!answer || answer.skipped)
102
+ return `**${spec.header}:** _Skipped_`;
103
+ if (answer.indices.length === 0)
104
+ return `**${spec.header}:** _None selected_`;
105
+ const chosen = answer.indices
106
+ .map((i) => spec.options[i]?.label ?? "")
107
+ .filter(Boolean)
108
+ .map((label) => truncateLabel(label))
109
+ .join(", ");
110
+ return `**${spec.header}:** ${chosen}`;
111
+ });
112
+ return lines.join("\n");
113
+ }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Single serialized queue for every cross-session interactive prompt the TUI can show: write/
3
+ * process approvals, `/model`'s plugin-facing `select`, and `ask_user_question`/`/ask`'s question
4
+ * panel. At most one of these is ever on screen, regardless of how many sessions (root or nested
5
+ * subagents) are asking. Ordering is plain FIFO by arrival: simple, fair, and starvation-free —
6
+ * there is no root-over-subagent (or vice versa) priority. Generic and session-agnostic: it only
7
+ * ever sees an opaque `sessionId`/`label` pair, never anything subagent-specific.
8
+ *
9
+ * A job whose `signal` aborts before its turn is withdrawn without ever running: `onWithdrawn` is
10
+ * called once, in its place, and it never blocks the jobs behind it. A job that is already running
11
+ * when its own signal aborts is `run`'s own responsibility to unwind (mirroring the existing
12
+ * `approve`/`request.signal` abort-while-displayed pattern in app.ts) — the queue does not
13
+ * interrupt a job mid-flight, it only prevents queued jobs from ever starting once withdrawn.
14
+ */
15
+ export interface QueueAsker {
16
+ sessionId?: string;
17
+ label?: string;
18
+ }
19
+ export interface QueueJob<T> extends QueueAsker {
20
+ signal?: AbortSignal;
21
+ run: () => Promise<T>;
22
+ /** Called exactly once, instead of `run`, when withdrawn (aborted) before its turn arrives. */
23
+ onWithdrawn: () => T;
24
+ }
25
+ export declare class InteractiveQueue {
26
+ private queue;
27
+ private active;
28
+ submit<T>(job: QueueJob<T>): Promise<T>;
29
+ private advance;
30
+ /** The currently running job's asker, if any. */
31
+ current(): QueueAsker | undefined;
32
+ isQueued(sessionId: string): boolean;
33
+ /** Currently displayed OR waiting behind another prompt — both count as "waiting on the user". */
34
+ isWaiting(sessionId: string): boolean;
35
+ }
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Single serialized queue for every cross-session interactive prompt the TUI can show: write/
3
+ * process approvals, `/model`'s plugin-facing `select`, and `ask_user_question`/`/ask`'s question
4
+ * panel. At most one of these is ever on screen, regardless of how many sessions (root or nested
5
+ * subagents) are asking. Ordering is plain FIFO by arrival: simple, fair, and starvation-free —
6
+ * there is no root-over-subagent (or vice versa) priority. Generic and session-agnostic: it only
7
+ * ever sees an opaque `sessionId`/`label` pair, never anything subagent-specific.
8
+ *
9
+ * A job whose `signal` aborts before its turn is withdrawn without ever running: `onWithdrawn` is
10
+ * called once, in its place, and it never blocks the jobs behind it. A job that is already running
11
+ * when its own signal aborts is `run`'s own responsibility to unwind (mirroring the existing
12
+ * `approve`/`request.signal` abort-while-displayed pattern in app.ts) — the queue does not
13
+ * interrupt a job mid-flight, it only prevents queued jobs from ever starting once withdrawn.
14
+ */
15
+ export class InteractiveQueue {
16
+ queue = [];
17
+ active;
18
+ submit(job) {
19
+ return new Promise((resolve, reject) => {
20
+ let settled = false;
21
+ const asker = { sessionId: job.sessionId, label: job.label };
22
+ const entry = {
23
+ asker,
24
+ start: () => {
25
+ settled = true;
26
+ this.active = asker;
27
+ // Clear `active` and advance BEFORE settling the caller's promise, so a caller awaiting
28
+ // this job never observes a stale `current()`/`isWaiting()` in the same microtask.
29
+ job.run().then((value) => {
30
+ this.active = undefined;
31
+ this.advance();
32
+ resolve(value);
33
+ }, (error) => {
34
+ this.active = undefined;
35
+ this.advance();
36
+ reject(error);
37
+ });
38
+ },
39
+ };
40
+ const withdraw = () => {
41
+ if (settled)
42
+ return;
43
+ settled = true;
44
+ const i = this.queue.indexOf(entry);
45
+ if (i >= 0)
46
+ this.queue.splice(i, 1);
47
+ resolve(job.onWithdrawn());
48
+ };
49
+ if (job.signal) {
50
+ if (job.signal.aborted) {
51
+ withdraw();
52
+ return;
53
+ }
54
+ job.signal.addEventListener("abort", withdraw, { once: true });
55
+ }
56
+ this.queue.push(entry);
57
+ this.advance();
58
+ });
59
+ }
60
+ advance() {
61
+ if (this.active)
62
+ return;
63
+ const next = this.queue.shift();
64
+ if (!next)
65
+ return;
66
+ next.start();
67
+ }
68
+ /** The currently running job's asker, if any. */
69
+ current() {
70
+ return this.active ? { ...this.active } : undefined;
71
+ }
72
+ isQueued(sessionId) {
73
+ return this.queue.some((e) => e.asker.sessionId === sessionId);
74
+ }
75
+ /** Currently displayed OR waiting behind another prompt — both count as "waiting on the user". */
76
+ isWaiting(sessionId) {
77
+ return this.active?.sessionId === sessionId || this.isQueued(sessionId);
78
+ }
79
+ }
@@ -0,0 +1,67 @@
1
+ import type { Component } from "@earendil-works/pi-tui";
2
+ export type SkillSort = "name" | "source" | "tokens";
3
+ export interface SkillCatalogView {
4
+ id: string;
5
+ effectiveId: string;
6
+ displayId: string;
7
+ name: string;
8
+ description: string;
9
+ scope: "project" | "config" | "user" | "plugin";
10
+ source: string;
11
+ owner?: {
12
+ id: string;
13
+ name: string;
14
+ };
15
+ manageable: boolean;
16
+ locked: boolean;
17
+ enabled: boolean;
18
+ effective: boolean;
19
+ shadowedBy?: string;
20
+ approximateTokens: number;
21
+ }
22
+ export interface SkillManagerState {
23
+ query: string;
24
+ searching: boolean;
25
+ sort: SkillSort;
26
+ selectedId?: string;
27
+ offset: number;
28
+ }
29
+ export interface SkillViewport {
30
+ items: SkillCatalogView[];
31
+ selected: number;
32
+ offset: number;
33
+ above: number;
34
+ below: number;
35
+ }
36
+ export declare const initialSkillManagerState: (entries: SkillCatalogView[]) => SkillManagerState;
37
+ export declare function visibleSkills(entries: SkillCatalogView[], state: SkillManagerState): SkillCatalogView[];
38
+ export declare function retainSkillSelection(entries: SkillCatalogView[], state: SkillManagerState): SkillManagerState;
39
+ export declare function skillViewport(entries: SkillCatalogView[], state: SkillManagerState, capacity: number): SkillViewport;
40
+ export declare const cycleSkillSort: (sort: SkillSort) => SkillSort;
41
+ export declare function moveSkillSelection(entries: SkillCatalogView[], state: SkillManagerState, move: "up" | "down" | "pageUp" | "pageDown" | "home" | "end", capacity: number): SkillManagerState;
42
+ export declare class SkillsManager implements Component {
43
+ private entries;
44
+ private state;
45
+ private width;
46
+ private height;
47
+ private pending;
48
+ constructor(options: {
49
+ entries: SkillCatalogView[];
50
+ height: () => number;
51
+ onClose: () => void;
52
+ onToggle: (id: string, enabled: boolean) => Promise<SkillCatalogView>;
53
+ onError: (error: unknown) => void;
54
+ onChanged: (message: string) => void;
55
+ requestRender: () => void;
56
+ });
57
+ private onClose;
58
+ private onToggle;
59
+ private onError;
60
+ private onChanged;
61
+ private requestRender;
62
+ invalidate(): void;
63
+ private capacity;
64
+ private selected;
65
+ handleInput(data: string): void;
66
+ render(width: number): string[];
67
+ }