@adeildo/pi-kit 4.0.0 → 5.0.0

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,71 @@
1
+ // In the harness every piece is an extension of its own, so the screen asks them over pi.events.
2
+ // The bus is synchronous: an app answers by writing on the payload, like the claim.
3
+ import type { Control, Json } from "../control.ts";
4
+ import type { Layer } from "../settings/store.ts";
5
+
6
+ export const LIST = "harness:screen:list";
7
+ export const APPLY = "harness:screen:apply";
8
+ export const RUN = "harness:screen:run";
9
+ export const DONE = "harness:screen:done";
10
+ export const CHANGED = "harness:settings:changed";
11
+
12
+ export type RowKind = "setting" | "value" | "action" | "info";
13
+
14
+ export interface RowView {
15
+ feature: string;
16
+ id: string;
17
+ kind: RowKind;
18
+ section: string;
19
+ label: string;
20
+ description: string;
21
+ control?: Control;
22
+ value?: Json;
23
+ text?: string;
24
+ confirm?: string;
25
+ /** How deep the row sits under its section. Each step indents two columns. */
26
+ indent?: number;
27
+ /** Where a value row applies, when that is not this session. */
28
+ meta?: string;
29
+ layer?: Layer;
30
+ fallback?: Json;
31
+ /** The global value a project value hides. */
32
+ hidden?: Json;
33
+ restart?: boolean;
34
+ }
35
+
36
+ export interface TabView {
37
+ title: string;
38
+ sections: string[];
39
+ rows: RowView[];
40
+ }
41
+
42
+ export interface ListRequest {
43
+ tabs: TabView[];
44
+ }
45
+
46
+ /** `unset` drops the value that wins, so the one below shows. */
47
+ export interface ApplyRequest {
48
+ feature: string;
49
+ id: string;
50
+ op: "set" | "unset";
51
+ value?: Json;
52
+ answer?: { error?: string };
53
+ }
54
+
55
+ export interface RunRequest {
56
+ feature: string;
57
+ id: string;
58
+ request: string;
59
+ answer?: { error?: string };
60
+ }
61
+
62
+ export interface RunDone {
63
+ request: string;
64
+ text?: string;
65
+ error?: string;
66
+ }
67
+
68
+ export interface Changed {
69
+ /** The store that wrote, so it skips its own write. */
70
+ source: string;
71
+ }
package/src/control.ts ADDED
@@ -0,0 +1,59 @@
1
+ // How the settings screen edits a value. Plain JSON, because the screen may live in another package.
2
+ export type Json = string | number | boolean | null | Json[] | { [key: string]: Json };
3
+
4
+ export interface ControlOption {
5
+ value: Json;
6
+ label?: string;
7
+ description?: string;
8
+ }
9
+
10
+ export type Control =
11
+ | { type: "toggle" }
12
+ | { type: "choice"; options: ControlOption[]; custom?: boolean }
13
+ | { type: "number"; min?: number; max?: number; step?: number; unit?: string; nullable?: boolean }
14
+ | { type: "text"; multiline?: boolean; presets?: ControlOption[] }
15
+ | { type: "list"; options?: ControlOption[] };
16
+
17
+ export function formatValue(control: Control, value: Json | undefined): string {
18
+ if (value === undefined || value === null) return control.type === "number" ? "none" : "";
19
+ switch (control.type) {
20
+ case "toggle":
21
+ return value === true ? "on" : "off";
22
+ case "choice":
23
+ return optionLabel(control.options, value);
24
+ case "number":
25
+ return control.unit === undefined ? String(value) : `${value}${control.unit}`;
26
+ case "text":
27
+ if (typeof value !== "string") return JSON.stringify(value);
28
+ if (control.presets !== undefined) {
29
+ const preset = control.presets.find((option) => option.value === value);
30
+ if (preset !== undefined) return preset.label ?? String(preset.value);
31
+ if (value.trim() === "") return "(empty)";
32
+ return "custom";
33
+ }
34
+ return firstLine(value);
35
+ case "list":
36
+ if (!Array.isArray(value)) return JSON.stringify(value);
37
+ if (value.length === 0) return "(none)";
38
+ return value.map((entry) => optionLabel(control.options ?? [], entry)).join(", ");
39
+ }
40
+ }
41
+
42
+ export function optionLabel(options: readonly ControlOption[], value: Json): string {
43
+ const option = options.find((entry) => sameJson(entry.value, value));
44
+ return optionText(option ?? { value });
45
+ }
46
+
47
+ export function optionText(option: ControlOption): string {
48
+ if (option.label !== undefined) return option.label;
49
+ return typeof option.value === "string" ? option.value : JSON.stringify(option.value);
50
+ }
51
+
52
+ export function sameJson(left: unknown, right: unknown): boolean {
53
+ return JSON.stringify(left) === JSON.stringify(right);
54
+ }
55
+
56
+ function firstLine(text: string): string {
57
+ const line = text.split("\n")[0] ?? "";
58
+ return text.includes("\n") ? `${line} …` : line;
59
+ }
package/src/decode.ts CHANGED
@@ -1,5 +1,8 @@
1
1
  // Decoders for everything that comes from outside: settings files and event payloads from other
2
- // packages. The only module that inspects `typeof`, and no decoder throws.
2
+ // packages. The only module that inspects `typeof`, and no decoder throws. `control` is how the
3
+ // settings screen edits the value.
4
+ import type { Control } from "./control.ts";
5
+
3
6
  export interface Problem {
4
7
  path: string;
5
8
  message: string;
@@ -11,6 +14,7 @@ export type Decoded<T> =
11
14
 
12
15
  export interface Decoder<T> {
13
16
  decode(input: unknown, path: string): Decoded<T>;
17
+ control?: Control;
14
18
  }
15
19
 
16
20
  export function pass<T>(value: T, problems: Problem[] = []): Decoded<T> {
@@ -38,12 +42,14 @@ export function isObject(input: unknown): input is Record<string, unknown> {
38
42
  }
39
43
 
40
44
  export const string: Decoder<string> = {
45
+ control: { type: "text" },
41
46
  decode(input, path) {
42
47
  return typeof input === "string" ? pass(input) : fail(problem(path, "expected a string"));
43
48
  },
44
49
  };
45
50
 
46
51
  export const trimmedString: Decoder<string> = {
52
+ control: { type: "text" },
47
53
  decode(input, path) {
48
54
  if (typeof input !== "string" || input.trim() === "")
49
55
  return fail(problem(path, "expected a non-empty string"));
@@ -52,12 +58,14 @@ export const trimmedString: Decoder<string> = {
52
58
  };
53
59
 
54
60
  export const boolean: Decoder<boolean> = {
61
+ control: { type: "toggle" },
55
62
  decode(input, path) {
56
63
  return typeof input === "boolean" ? pass(input) : fail(problem(path, "expected a boolean"));
57
64
  },
58
65
  };
59
66
 
60
67
  export const unit: Decoder<number> = {
68
+ control: { type: "number", min: 0, max: 1, step: 0.05 },
61
69
  decode(input, path) {
62
70
  if (typeof input !== "number" || !Number.isFinite(input) || input < 0 || input > 1)
63
71
  return fail(problem(path, "expected a number from 0 to 1"));
@@ -66,6 +74,7 @@ export const unit: Decoder<number> = {
66
74
  };
67
75
 
68
76
  export const duration: Decoder<number> = {
77
+ control: { type: "number", min: 0, step: 500, unit: "ms" },
69
78
  decode(input, path) {
70
79
  if (typeof input !== "number" || !Number.isFinite(input) || input < 0)
71
80
  return fail(problem(path, "expected a non-negative number of milliseconds"));
@@ -76,6 +85,7 @@ export const duration: Decoder<number> = {
76
85
  /** A whole number from `min` to `max`, inclusive. */
77
86
  export function integer(min: number, max: number): Decoder<number> {
78
87
  return {
88
+ control: { type: "number", min, max, step: 1 },
79
89
  decode(input, path) {
80
90
  if (typeof input !== "number" || !Number.isInteger(input) || input < min || input > max) {
81
91
  return fail(problem(path, `expected a whole number from ${min} to ${max}`));
@@ -88,6 +98,7 @@ export function integer(min: number, max: number): Decoder<number> {
88
98
  /** A string that matches `pattern`, described to the user as `expected`. */
89
99
  export function matching(pattern: RegExp, expected: string): Decoder<string> {
90
100
  return {
101
+ control: { type: "text" },
91
102
  decode(input, path) {
92
103
  if (typeof input === "string" && pattern.test(input)) return pass(input);
93
104
  return fail(problem(path, `expected ${expected}`));
@@ -98,6 +109,7 @@ export function matching(pattern: RegExp, expected: string): Decoder<string> {
98
109
  export function literal<const T extends readonly string[]>(...values: T): Decoder<T[number]> {
99
110
  const expected = values.map((value) => `"${value}"`).join(" or ");
100
111
  return {
112
+ control: { type: "choice", options: values.map((value) => ({ value })) },
101
113
  decode(input, path) {
102
114
  if (typeof input === "string" && (values as readonly string[]).includes(input))
103
115
  return pass(input as T[number]);
@@ -107,7 +119,10 @@ export function literal<const T extends readonly string[]>(...values: T): Decode
107
119
  }
108
120
 
109
121
  export function nullable<T>(inner: Decoder<T>): Decoder<T | null> {
122
+ const control: Control | undefined =
123
+ inner.control?.type === "number" ? { ...inner.control, nullable: true } : inner.control;
110
124
  return {
125
+ control,
111
126
  decode(input, path) {
112
127
  return input === null ? pass(null) : inner.decode(input, path);
113
128
  },
@@ -116,6 +131,7 @@ export function nullable<T>(inner: Decoder<T>): Decoder<T | null> {
116
131
 
117
132
  export function withDefaultOf<T>(inner: Decoder<T>, fallback: () => T): Decoder<T> {
118
133
  return {
134
+ control: inner.control,
119
135
  decode(input, path) {
120
136
  if (input === undefined) return pass(fallback());
121
137
  const result = inner.decode(input, path);
@@ -133,6 +149,7 @@ export function stringList(expected: string): Decoder<string[]> {
133
149
  const dropped = "ignored entries that are not non-empty strings";
134
150
 
135
151
  return {
152
+ control: { type: "list" },
136
153
  decode(input, path) {
137
154
  if (!Array.isArray(input)) return fail(problem(path, notAList));
138
155
 
@@ -155,6 +172,7 @@ export function stringListOrEmpty(
155
172
  ): Decoder<string[]> {
156
173
  const parse = stringList(expected);
157
174
  return {
175
+ control: parse.control,
158
176
  decode(input, path) {
159
177
  if (input === undefined) return pass([...fallback]);
160
178
  const result = parse.decode(input, path);
package/src/index.ts CHANGED
@@ -1,7 +1,16 @@
1
+ export { askQuestions, canAsk } from "./ask/client.ts";
1
2
  export * from "./app/builder.ts";
2
3
  export * from "./app/feature.ts";
4
+ export * from "./contracts/ask.ts";
5
+ export * from "./contracts/screen.ts";
6
+ export * from "./control.ts";
3
7
  export * from "./decode.ts";
4
8
  export * from "./events.ts";
5
- export { globalSettingsPath, projectSettingsPath } from "./settings/files.ts";
9
+ export {
10
+ globalSettingsPath,
11
+ projectSettingsPath,
12
+ readSettingsFile,
13
+ writeSettingsFile,
14
+ } from "./settings/files.ts";
6
15
  export * from "./settings/setting.ts";
7
16
  export * from "./settings/store.ts";
@@ -0,0 +1,75 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+
3
+ import {
4
+ APPLY,
5
+ type ApplyRequest,
6
+ DONE,
7
+ LIST,
8
+ type ListRequest,
9
+ type RowView,
10
+ RUN,
11
+ type RunDone,
12
+ type RunRequest,
13
+ type TabView,
14
+ } from "../contracts/screen.ts";
15
+ import type { Json } from "../control.ts";
16
+ import { isObject } from "../decode.ts";
17
+
18
+ type Events = ExtensionAPI["events"];
19
+
20
+ /** Tabs with the same title, from features of different packages, become one. */
21
+ export function listTabs(events: Events): TabView[] {
22
+ const request: ListRequest = { tabs: [] };
23
+ events.emit(LIST, request);
24
+
25
+ const merged: TabView[] = [];
26
+ for (const tab of request.tabs) {
27
+ if (tab.rows.length === 0) continue;
28
+ const same = merged.find((entry) => entry.title === tab.title);
29
+ if (same === undefined) {
30
+ merged.push({ title: tab.title, sections: [...tab.sections], rows: [...tab.rows] });
31
+ continue;
32
+ }
33
+ for (const section of tab.sections)
34
+ if (!same.sections.includes(section)) same.sections.push(section);
35
+ same.rows.push(...tab.rows);
36
+ }
37
+ return merged;
38
+ }
39
+
40
+ /** Returns why the change was refused. */
41
+ export function applyRow(
42
+ events: Events,
43
+ row: RowView,
44
+ op: ApplyRequest["op"],
45
+ value?: Json,
46
+ ): string | undefined {
47
+ const request: ApplyRequest = { feature: row.feature, id: row.id, op };
48
+ if (value !== undefined) request.value = value;
49
+ events.emit(APPLY, request);
50
+ if (request.answer === undefined) return `${row.feature} is not running`;
51
+ return request.answer.error;
52
+ }
53
+
54
+ export function runRow(events: Events, row: RowView): Promise<Omit<RunDone, "request">> {
55
+ const id = crypto.randomUUID();
56
+ return new Promise((resolve) => {
57
+ const stop = events.on(DONE, (data) => {
58
+ if (!isObject(data) || data.request !== id) return;
59
+ stop();
60
+ resolve({
61
+ ...(typeof data.text === "string" ? { text: data.text } : {}),
62
+ ...(typeof data.error === "string" ? { error: data.error } : {}),
63
+ });
64
+ });
65
+
66
+ const request: RunRequest = { feature: row.feature, id: row.id, request: id };
67
+ events.emit(RUN, request);
68
+ const refused =
69
+ request.answer === undefined ? `${row.feature} is not running` : request.answer.error;
70
+ if (refused !== undefined) {
71
+ stop();
72
+ resolve({ error: refused });
73
+ }
74
+ });
75
+ }
@@ -0,0 +1,61 @@
1
+ // Every app brings this feature, and the claim keeps the first copy.
2
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
3
+ import { Key } from "@earendil-works/pi-tui";
4
+
5
+ import type { Feature, FeatureScope } from "../app/feature.ts";
6
+ import { formatValue } from "../control.ts";
7
+ import { applyRow, listTabs } from "./client.ts";
8
+ import { ScreenModel } from "./model.ts";
9
+ import { type ScreenResult, ScreenView } from "./view.ts";
10
+
11
+ export const settingsScreen: Feature = {
12
+ id: "settings-screen",
13
+ description: "One screen for every setting of the harness",
14
+ setup(scope) {
15
+ scope.registerCommand("harness", {
16
+ description: "Settings for every harness piece (also Alt+S)",
17
+ getArgumentCompletions: (prefix) => {
18
+ const typed = prefix.trim().toLowerCase();
19
+ const tabs = listTabs(scope.events).map((tab) => tab.title.toLowerCase());
20
+ const matches = tabs.filter((tab) => tab.startsWith(typed));
21
+ return matches.length === 0 ? null : matches.map((tab) => ({ value: tab, label: tab }));
22
+ },
23
+ handler: (args, ctx) => openScreen(scope, ctx, args),
24
+ });
25
+ scope.registerShortcut(Key.alt("s"), {
26
+ description: "Settings for every harness piece",
27
+ handler: (ctx) => openScreen(scope, ctx, ""),
28
+ });
29
+ },
30
+ };
31
+
32
+ async function openScreen(scope: FeatureScope, ctx: ExtensionContext, tab: string): Promise<void> {
33
+ if (ctx.mode !== "tui") {
34
+ ctx.ui.notify("the settings screen needs the interactive terminal", "warning");
35
+ return;
36
+ }
37
+ const model = new ScreenModel(listTabs(scope.events));
38
+ if (tab.trim() !== "") model.openTab(tab);
39
+
40
+ // The editor for long text is pi's own, so the screen closes for it and opens where it was.
41
+ for (;;) {
42
+ // oxlint-disable-next-line no-await-in-loop -- one screen at a time
43
+ const result = await ctx.ui.custom<ScreenResult>(
44
+ (tui, theme, _keybindings, done) =>
45
+ new ScreenView({ tui, theme, events: scope.events, model, done }),
46
+ { overlay: true, overlayOptions: { width: "100%", maxHeight: "100%", anchor: "top-left" } },
47
+ );
48
+ if (result?.kind !== "edit") return;
49
+
50
+ const row = result.row;
51
+ const current = typeof row.value === "string" ? row.value : "";
52
+ // oxlint-disable-next-line no-await-in-loop -- the screen waits for the editor
53
+ const text = await ctx.ui.editor(row.label, current);
54
+ if (text !== undefined && text !== current) {
55
+ const error = applyRow(scope.events, row, "set", text);
56
+ const shown = row.control === undefined ? "" : formatValue(row.control, text);
57
+ ctx.ui.notify(error ?? `${row.label}: ${shown}`, error === undefined ? "info" : "error");
58
+ }
59
+ model.refresh(listTabs(scope.events));
60
+ }
61
+ }
@@ -0,0 +1,173 @@
1
+ import { fuzzyFilter } from "@earendil-works/pi-tui";
2
+
3
+ import type { RowView, TabView } from "../contracts/screen.ts";
4
+
5
+ export type Line = { kind: "heading"; title: string } | { kind: "row"; row: RowView; tab: string };
6
+
7
+ export interface SectionMark {
8
+ title: string;
9
+ active: boolean;
10
+ }
11
+
12
+ export class ScreenModel {
13
+ tabs: TabView[];
14
+ tab = 0;
15
+ query = "";
16
+ #cursor: string | undefined;
17
+
18
+ constructor(tabs: TabView[]) {
19
+ this.tabs = tabs;
20
+ this.#cursor = this.#firstKey();
21
+ }
22
+
23
+ get searching(): boolean {
24
+ return this.query !== "";
25
+ }
26
+
27
+ /** Keeps the tab and the row under the cursor. */
28
+ refresh(tabs: TabView[]): void {
29
+ const title = this.tabs[this.tab]?.title;
30
+ this.tabs = tabs;
31
+ const index = tabs.findIndex((entry) => entry.title === title);
32
+ this.tab = index === -1 ? 0 : index;
33
+ if (this.#index() === -1) this.#cursor = this.#firstKey();
34
+ }
35
+
36
+ lines(): Line[] {
37
+ return this.searching ? this.#results() : this.#tabLines();
38
+ }
39
+
40
+ selected(): Extract<Line, { kind: "row" }> | undefined {
41
+ const line = this.lines()[this.#index()];
42
+ return line?.kind === "row" ? line : undefined;
43
+ }
44
+
45
+ cursorLine(): number {
46
+ return this.#index();
47
+ }
48
+
49
+ move(delta: number): void {
50
+ const rows = this.#rowIndexes();
51
+ if (rows.length === 0) return;
52
+ const at = rows.indexOf(this.#index());
53
+ const next = Math.min(rows.length - 1, Math.max(0, (at === -1 ? 0 : at) + delta));
54
+ this.#select(rows[next]);
55
+ }
56
+
57
+ moveToEdge(edge: "first" | "last"): void {
58
+ const rows = this.#rowIndexes();
59
+ this.#select(edge === "first" ? rows[0] : rows.at(-1));
60
+ }
61
+
62
+ jumpSection(direction: 1 | -1): void {
63
+ const lines = this.lines();
64
+ const starts: number[] = [];
65
+ lines.forEach((line, index) => {
66
+ if (line.kind === "heading" && lines[index + 1]?.kind === "row") starts.push(index + 1);
67
+ });
68
+ if (starts.length === 0) return;
69
+
70
+ const at = this.#index();
71
+ const current = starts.findLastIndex((start) => start <= at);
72
+ const next = (current + direction + starts.length) % starts.length;
73
+ this.#select(starts[next]);
74
+ }
75
+
76
+ switchTab(delta: number): void {
77
+ if (this.searching || this.tabs.length === 0) return;
78
+ this.tab = (this.tab + delta + this.tabs.length) % this.tabs.length;
79
+ this.#cursor = this.#firstKey();
80
+ }
81
+
82
+ openTab(title: string): boolean {
83
+ const wanted = title.trim().toLowerCase();
84
+ const index = this.tabs.findIndex((entry) => entry.title.toLowerCase().startsWith(wanted));
85
+ if (index === -1) return false;
86
+ this.tab = index;
87
+ this.#cursor = this.#firstKey();
88
+ return true;
89
+ }
90
+
91
+ setQuery(query: string): void {
92
+ this.query = query;
93
+ this.#cursor = this.#firstKey();
94
+ }
95
+
96
+ sections(): SectionMark[] {
97
+ const lines = this.lines();
98
+ const at = this.#index();
99
+ let active = "";
100
+ lines.forEach((line, index) => {
101
+ if (line.kind === "heading" && index <= at) active = line.title;
102
+ });
103
+ return lines
104
+ .filter((line): line is Extract<Line, { kind: "heading" }> => line.kind === "heading")
105
+ .map((line) => ({ title: line.title, active: line.title === active }));
106
+ }
107
+
108
+ #tabLines(): Line[] {
109
+ const tab = this.tabs[this.tab];
110
+ if (tab === undefined) return [];
111
+ const lines: Line[] = [];
112
+ for (const section of tab.sections) {
113
+ const rows = tab.rows.filter((row) => row.section === section);
114
+ if (rows.length === 0) continue;
115
+ lines.push({ kind: "heading", title: section });
116
+ for (const row of rows) lines.push({ kind: "row", row, tab: tab.title });
117
+ }
118
+ return lines;
119
+ }
120
+
121
+ #results(): Line[] {
122
+ const all = this.tabs.flatMap((tab) => tab.rows.map((row) => ({ row, tab: tab.title })));
123
+ // The query picks the rows. The order stays the tab's.
124
+ const matched = new Set(
125
+ fuzzyFilter(all, this.query, ({ row, tab }) =>
126
+ [row.label, row.section, tab, row.id].join(" "),
127
+ ),
128
+ );
129
+ const found = all.filter((hit) => matched.has(hit));
130
+
131
+ const groups = new Map<string, typeof found>();
132
+ for (const hit of found) {
133
+ const title = `${hit.tab} \u203a ${hit.row.section}`;
134
+ groups.set(title, [...(groups.get(title) ?? []), hit]);
135
+ }
136
+ const lines: Line[] = [];
137
+ for (const [title, hits] of groups) {
138
+ lines.push({ kind: "heading", title });
139
+ for (const hit of hits) lines.push({ kind: "row", ...hit });
140
+ }
141
+ return lines;
142
+ }
143
+
144
+ #rowIndexes(): number[] {
145
+ const indexes: number[] = [];
146
+ this.lines().forEach((line, index) => {
147
+ if (line.kind === "row") indexes.push(index);
148
+ });
149
+ return indexes;
150
+ }
151
+
152
+ #index(): number {
153
+ if (this.#cursor === undefined) return -1;
154
+ return this.lines().findIndex(
155
+ (line) => line.kind === "row" && keyOf(line.row) === this.#cursor,
156
+ );
157
+ }
158
+
159
+ #select(index: number | undefined): void {
160
+ if (index === undefined) return;
161
+ const line = this.lines()[index];
162
+ if (line?.kind === "row") this.#cursor = keyOf(line.row);
163
+ }
164
+
165
+ #firstKey(): string | undefined {
166
+ const line = this.lines().find((entry) => entry.kind === "row");
167
+ return line?.kind === "row" ? keyOf(line.row) : undefined;
168
+ }
169
+ }
170
+
171
+ export function keyOf(row: RowView): string {
172
+ return `${row.feature}\u0000${row.id}`;
173
+ }