@huseynsnmz/pi-suite 0.1.0 → 0.2.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.
Files changed (93) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/README.md +2 -1
  3. package/package.json +6 -7
  4. package/packages/board/package.json +0 -2
  5. package/packages/board/src/core/rows.mjs +21 -5
  6. package/packages/board/src/ui/dashboard.ts +36 -20
  7. package/packages/commands/LICENSE +21 -0
  8. package/packages/commands/README.md +19 -0
  9. package/packages/commands/package.json +22 -0
  10. package/packages/commands/src/index.ts +43 -0
  11. package/packages/commands/src/security-review.ts +109 -0
  12. package/packages/frame/README.md +1 -1
  13. package/packages/frame/package.json +1 -4
  14. package/packages/frame/src/data/github.ts +1 -1
  15. package/packages/frame/src/data/mode.ts +28 -2
  16. package/packages/frame/src/data/snapshot.ts +1 -1
  17. package/packages/frame/src/data/start-notes.ts +1 -1
  18. package/packages/frame/src/data/totals.ts +2 -2
  19. package/packages/frame/src/data/visits.ts +1 -1
  20. package/packages/frame/src/index.ts +22 -2
  21. package/packages/frame/src/kit/LICENSE +21 -0
  22. package/packages/frame/src/kit/app/attribution.ts +30 -0
  23. package/packages/frame/src/kit/app/builder.ts +186 -0
  24. package/packages/frame/src/kit/app/claim.ts +27 -0
  25. package/packages/frame/src/kit/app/feature.ts +91 -0
  26. package/packages/frame/src/kit/app/host.ts +214 -0
  27. package/packages/frame/src/kit/app/renderers.ts +62 -0
  28. package/packages/frame/src/kit/app/scope.ts +125 -0
  29. package/packages/frame/src/kit/board/model.ts +337 -0
  30. package/packages/frame/src/kit/contract.ts +79 -0
  31. package/packages/frame/src/kit/contracts/accounts.ts +33 -0
  32. package/packages/frame/src/kit/contracts/ask.ts +120 -0
  33. package/packages/frame/src/kit/contracts/board.ts +6 -0
  34. package/packages/frame/src/kit/contracts/by.ts +22 -0
  35. package/packages/frame/src/kit/contracts/calls.ts +46 -0
  36. package/packages/frame/src/kit/contracts/events.ts +89 -0
  37. package/packages/frame/src/kit/contracts/mode.ts +26 -0
  38. package/packages/frame/src/kit/contracts/names.ts +12 -0
  39. package/packages/frame/src/kit/contracts/peers.ts +77 -0
  40. package/packages/frame/src/kit/contracts/prompts.ts +8 -0
  41. package/packages/frame/src/kit/contracts/screen.ts +75 -0
  42. package/packages/frame/src/kit/contracts/skills.ts +81 -0
  43. package/packages/frame/src/kit/contracts/sources.ts +81 -0
  44. package/packages/frame/src/kit/contracts/views.ts +42 -0
  45. package/packages/frame/src/kit/control.ts +59 -0
  46. package/packages/frame/src/kit/dark-theme.ts +41 -0
  47. package/packages/frame/src/kit/decode.ts +210 -0
  48. package/packages/frame/src/kit/events/log.ts +313 -0
  49. package/packages/frame/src/kit/format.ts +25 -0
  50. package/packages/frame/src/kit/host.ts +4 -0
  51. package/packages/frame/src/kit/index.ts +25 -0
  52. package/packages/frame/src/kit/legacy-tasks.ts +39 -0
  53. package/packages/frame/src/kit/overlay-images.ts +36 -0
  54. package/packages/frame/src/kit/paths.ts +16 -0
  55. package/packages/frame/src/kit/screen/client.ts +74 -0
  56. package/packages/frame/src/kit/screen/feature.ts +64 -0
  57. package/packages/frame/src/kit/screen/model.ts +182 -0
  58. package/packages/frame/src/kit/screen/view.ts +838 -0
  59. package/packages/frame/src/kit/settings/files.ts +90 -0
  60. package/packages/frame/src/kit/settings/setting.ts +57 -0
  61. package/packages/frame/src/kit/settings/store.ts +192 -0
  62. package/packages/frame/src/kit/sources/file.ts +129 -0
  63. package/packages/frame/src/kit/sources/follow.ts +155 -0
  64. package/packages/frame/src/kit/testing.ts +260 -0
  65. package/packages/frame/src/kit/totals.ts +72 -0
  66. package/packages/frame/src/kit/ui/entry.ts +75 -0
  67. package/packages/frame/src/kit/ui/frame.ts +212 -0
  68. package/packages/frame/src/kit/ui/glyphs.ts +132 -0
  69. package/packages/frame/src/kit/ui/scroll.ts +14 -0
  70. package/packages/frame/src/render/diff.ts +55 -2
  71. package/packages/frame/src/render/fit.ts +1 -1
  72. package/packages/frame/src/render/format.ts +1 -1
  73. package/packages/frame/src/render/segments.ts +1 -1
  74. package/packages/frame/src/render/streaming.ts +1 -1
  75. package/packages/frame/src/render/tools.ts +1 -1
  76. package/packages/frame/src/settings.ts +1 -1
  77. package/packages/frame/src/ui/editor.ts +1 -1
  78. package/packages/frame/src/ui/effort.ts +1 -1
  79. package/packages/frame/src/ui/header.ts +1 -1
  80. package/packages/frame/src/ui/outputs.ts +1 -1
  81. package/packages/frame/src/ui/pi-entries.ts +28 -6
  82. package/packages/frame/src/ui/previews.ts +1 -1
  83. package/packages/frame/src/ui/queue.ts +1 -1
  84. package/packages/frame/src/ui/todos.ts +1 -1
  85. package/packages/frame/src/ui/tool-frame.ts +1 -1
  86. package/packages/frame/src/view/state.ts +1 -1
  87. package/packages/frame/src/view/transcript.ts +1 -1
  88. package/packages/frame/src/view/view.ts +1 -1
  89. package/packages/web/package.json +1 -3
  90. package/packages/web/src/extract.ts +2 -2
  91. package/packages/web/src/markdown.ts +122 -0
  92. package/src/index.ts +1 -1
  93. package/src/settings.ts +1 -0
@@ -17,7 +17,7 @@ import {
17
17
  type SessionSource,
18
18
  skillNames,
19
19
  sourceFor,
20
- } from "@adeildo/pi-kit";
20
+ } from "./kit/index.ts";
21
21
  import type {
22
22
  ExtensionAPI,
23
23
  ExtensionContext,
@@ -28,7 +28,7 @@ import { Key, type KeyId, type TUI } from "@earendil-works/pi-tui";
28
28
 
29
29
  import { type GitState, readGit } from "./data/git.ts";
30
30
  import { cmdAsKey, SETTINGS_SHORTCUT, watchCmdKey } from "./render/keys.ts";
31
- import { frameColorOf } from "./data/mode.ts";
31
+ import { frameColorOf, type Mode, modeOf, SENT_IN_ENTRY, sentInFromEntries } from "./data/mode.ts";
32
32
  import { type GithubState, readGithub } from "./data/github.ts";
33
33
  import { emptySnapshot, type LiveInputs, type Snapshot, SnapshotReader } from "./data/snapshot.ts";
34
34
  import type { LastVisit } from "./data/start-notes.ts";
@@ -126,6 +126,8 @@ export const frameFeature = defineFeature({
126
126
  registerToolFrames(scope);
127
127
  registerEffort(scope);
128
128
  registerTodos(scope, () => glyphs().set);
129
+ const sentLive = new Map<string, Mode>();
130
+ let sentSaved = { length: -1, modes: new Map<string, Mode>() };
129
131
  framePiEntries(() => {
130
132
  if (live.ctx === undefined) return undefined;
131
133
  return {
@@ -133,6 +135,12 @@ export const frameFeature = defineFeature({
133
135
  set: glyphs().set,
134
136
  you: userFrame.get(scope),
135
137
  from: (text: string) => PROMPT_FROM.current(scope, text)?.name,
138
+ sentIn: (id: string | undefined, text: string) => {
139
+ if (id === undefined) return sentLive.get(text);
140
+ const entries = live.ctx?.sessionManager.getEntries() ?? [];
141
+ if (entries.length !== sentSaved.length) sentSaved = { length: entries.length, modes: sentInFromEntries(entries) };
142
+ return sentSaved.modes.get(id);
143
+ },
136
144
  };
137
145
  });
138
146
  showTimings((message, width) => {
@@ -215,6 +223,7 @@ export const frameFeature = defineFeature({
215
223
  live.ctx = ctx;
216
224
  telemetry.reset();
217
225
  telemetry.restore(ctx.sessionManager.getEntries());
226
+ sentLive.clear();
218
227
  void probeGit(live);
219
228
  if (ctx.mode !== "tui") return;
220
229
 
@@ -242,6 +251,17 @@ export const frameFeature = defineFeature({
242
251
  showHeader(ctx);
243
252
  });
244
253
 
254
+ scope.on("message_start", (event) => {
255
+ if (event.message.role !== "user") return;
256
+ const mode = modeOf(live.footerData?.getExtensionStatuses() ?? new Map());
257
+ if (mode === undefined) return;
258
+ const { content, timestamp } = event.message;
259
+ const text = typeof content === "string" ? content : content.flatMap((part) => (part.type === "text" ? [part.text] : [])).join("");
260
+ sentLive.set(text, mode);
261
+ // Normal draws as a plain message, so a resumed session needs nothing saved for it.
262
+ if (mode !== "normal") scope.appendEntry(SENT_IN_ENTRY, { at: timestamp, mode });
263
+ });
264
+
245
265
  scope.onShutdown(() => {
246
266
  stopCmdKeys?.();
247
267
  stopCmdKeys = undefined;
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 adeildo
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,30 @@
1
+ // In the harness every feature runs inside one extension, so pi reports every failure under the
2
+ // same name. These put the app and the feature in front of it instead.
3
+
4
+ export function describe(error: unknown): string {
5
+ return error instanceof Error ? error.message : String(error);
6
+ }
7
+
8
+ // A sync function stays sync, because pi treats some handler results differently when they are
9
+ // promises.
10
+ export function attributed<A extends unknown[], R>(
11
+ label: string,
12
+ run: (...args: A) => R,
13
+ ): (...args: A) => R {
14
+ return (...args: A): R => {
15
+ let result: R;
16
+ try {
17
+ result = run(...args);
18
+ } catch (error) {
19
+ throw relabel(label, error);
20
+ }
21
+ if (!(result instanceof Promise)) return result;
22
+ return result.catch((error: unknown) => {
23
+ throw relabel(label, error);
24
+ }) as R;
25
+ };
26
+ }
27
+
28
+ function relabel(label: string, error: unknown): Error {
29
+ return new Error(`${label}: ${describe(error)}`, { cause: error });
30
+ }
@@ -0,0 +1,186 @@
1
+ // `createApp(pi, ...).use(feature).build()`. Registration happens only in the factory, session work
2
+ // from `session_start`, cleanup once per session, and a feature that fails to set up is a warning.
3
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
4
+
5
+ import { CHANGED } from "../contracts/screen.ts";
6
+ import { boolean, isObject } from "../decode.ts";
7
+ import { settingsScreen } from "../screen/feature.ts";
8
+ import { globalSettingsPath, projectSettingsPath } from "../settings/files.ts";
9
+ import { type Setting, type SettingsScope, setting } from "../settings/setting.ts";
10
+ import { SettingsStore } from "../settings/store.ts";
11
+ import { describe } from "./attribution.ts";
12
+ import { answerClaims, ownerOf } from "./claim.ts";
13
+ import type { Feature } from "./feature.ts";
14
+ import { serveScreen } from "./host.ts";
15
+ import { type AppState, createScope, type Hook } from "./scope.ts";
16
+
17
+ export interface App extends SettingsScope {
18
+ readonly name: string;
19
+ readonly settings: SettingsStore;
20
+ has(featureId: string): boolean;
21
+ }
22
+
23
+ export interface AppOptions {
24
+ name: string;
25
+ settingsPath?: string;
26
+ }
27
+
28
+ export interface AppBuilder {
29
+ use(feature: Feature): AppBuilder;
30
+ build(): App;
31
+ }
32
+
33
+ export function createApp(pi: ExtensionAPI, options: AppOptions): AppBuilder {
34
+ const features: Feature[] = [];
35
+ const builder: AppBuilder = {
36
+ use(feature) {
37
+ features.push(feature);
38
+ return builder;
39
+ },
40
+ build: () => mount(pi, options, features),
41
+ };
42
+ return builder;
43
+ }
44
+
45
+ export function enabledSetting(feature: Feature): Setting<boolean> {
46
+ return setting({
47
+ id: `features.${feature.id}.enabled`,
48
+ default: true,
49
+ decoder: boolean,
50
+ });
51
+ }
52
+
53
+ function mount(pi: ExtensionAPI, options: AppOptions, features: readonly Feature[]): App {
54
+ const settings = new SettingsStore(options.settingsPath ?? globalSettingsPath());
55
+ const mounted = new Set<string>();
56
+ const queued: string[] = [];
57
+ let session: ExtensionContext | undefined;
58
+ let live = true;
59
+
60
+ function deliver(line: string): void {
61
+ if (session === undefined) queued.push(line);
62
+ else if (session.hasUI) session.ui.notify(line, "warning");
63
+ else console.error(line);
64
+ }
65
+
66
+ const state: AppState = {
67
+ name: options.name,
68
+ pi,
69
+ settings,
70
+ mounted,
71
+ commands: new Map(),
72
+ starts: [],
73
+ shutdowns: [],
74
+ screen: new Map(),
75
+ screenGroups: new Map(),
76
+ live: () => live,
77
+ stop: () => {
78
+ live = false;
79
+ },
80
+ report: (source, message) => deliver(`${options.name}: ${source}: ${message}`),
81
+ };
82
+ const app: App = { name: options.name, settings, has: (id) => mounted.has(id) };
83
+ const running: Feature[] = [];
84
+
85
+ answerClaims(pi, options.name, mounted, () => live);
86
+ serveScreen({
87
+ pi,
88
+ settings,
89
+ features: () => running,
90
+ screen: state.screen,
91
+ screenGroups: state.screenGroups,
92
+ session: () => session,
93
+ isLive: () => live,
94
+ });
95
+
96
+ // Every app writes the same files, so a write by one is read again by the others.
97
+ const store = crypto.randomUUID();
98
+ settings.onWrite(() => pi.events.emit(CHANGED, { source: store }));
99
+ pi.events.on(CHANGED, (data) => {
100
+ if (!live || !isObject(data) || data.source === store) return;
101
+ for (const line of settings.load(settings.projectPath)) state.report("settings", line);
102
+ });
103
+
104
+ // Registered first, so these run ahead of the features' own handlers.
105
+ pi.on("session_start", async (_event, ctx) => {
106
+ live = true;
107
+ session = ctx;
108
+ for (const line of queued.splice(0)) deliver(line);
109
+ const project = ctx.isProjectTrusted() ? projectSettingsPath(ctx.cwd) : undefined;
110
+ for (const line of settings.load(project)) state.report("settings", line);
111
+ await runInOrder(
112
+ state.starts,
113
+ (run) => run(ctx),
114
+ (source, error) => {
115
+ state.report(source, `failed to start the session: ${describe(error)}`);
116
+ },
117
+ );
118
+ });
119
+
120
+ pi.on("session_shutdown", async () => {
121
+ if (!live) return;
122
+ // Before the hooks: a hook stops a service, and a timer left running would read it after.
123
+ state.stop();
124
+ await runInOrder(
125
+ state.shutdowns.toReversed(),
126
+ (run) => run(),
127
+ (source, error) => {
128
+ state.report(source, `failed to shut down: ${describe(error)}`);
129
+ },
130
+ );
131
+ session = undefined;
132
+ });
133
+
134
+ // All the switches first, so one read of the file decides what is on.
135
+ const candidates: { feature: Feature; enabled: Setting<boolean> }[] = [];
136
+ for (const feature of [settingsScreen, ...features]) {
137
+ if (candidates.some((candidate) => candidate.feature.id === feature.id)) {
138
+ state.report(feature.id, "was added to this app twice; keeping the first");
139
+ continue;
140
+ }
141
+ const enabled = enabledSetting(feature);
142
+ try {
143
+ settings.register([enabled, ...(feature.settings ?? [])]);
144
+ candidates.push({ feature, enabled });
145
+ } catch (error) {
146
+ state.report(feature.id, `failed to set up: ${describe(error)}`);
147
+ }
148
+ }
149
+
150
+ for (const { feature, enabled } of candidates) {
151
+ if (!enabled.get(app)) continue;
152
+
153
+ const owner = ownerOf(pi, feature.id);
154
+ if (owner !== undefined) {
155
+ // Every app brings the screen, and one is enough.
156
+ if (feature !== settingsScreen)
157
+ state.report(feature.id, `already loaded by ${owner}, so this copy stays off`);
158
+ continue;
159
+ }
160
+
161
+ try {
162
+ feature.setup(createScope(state, feature.id));
163
+ mounted.add(feature.id);
164
+ running.push(feature);
165
+ } catch (error) {
166
+ state.report(feature.id, `failed to set up: ${describe(error)}`);
167
+ }
168
+ }
169
+
170
+ return app;
171
+ }
172
+
173
+ async function runInOrder<T>(
174
+ hooks: readonly Hook<T>[],
175
+ call: (run: T) => void | Promise<void>,
176
+ failed: (source: string, error: unknown) => void,
177
+ ): Promise<void> {
178
+ for (const hook of hooks) {
179
+ try {
180
+ // oxlint-disable-next-line no-await-in-loop -- the order is the point
181
+ await call(hook.run);
182
+ } catch (error) {
183
+ failed(hook.source, error);
184
+ }
185
+ }
186
+ }
@@ -0,0 +1,27 @@
1
+ // An app asks the others on pi.events whether one already runs a feature, so the harness and a
2
+ // standalone copy never run it twice. The bus is synchronous, so the answer is written on the payload.
3
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
4
+
5
+ import { isObject, object, string } from "../decode.ts";
6
+
7
+ const CHANNEL = "harness:kit:claim";
8
+ const claimShape = object({ feature: string });
9
+
10
+ export function answerClaims(
11
+ pi: ExtensionAPI,
12
+ appName: string,
13
+ mounted: ReadonlySet<string>,
14
+ isLive: () => boolean,
15
+ ): void {
16
+ pi.events.on(CHANNEL, (data) => {
17
+ const claim = claimShape.decode(data, "");
18
+ if (!isLive() || !claim.ok || !isObject(data) || data.owner !== undefined) return;
19
+ if (mounted.has(claim.value.feature)) data.owner = appName;
20
+ });
21
+ }
22
+
23
+ export function ownerOf(pi: ExtensionAPI, featureId: string): string | undefined {
24
+ const claim: { feature: string; owner?: unknown } = { feature: featureId };
25
+ pi.events.emit(CHANNEL, claim);
26
+ return typeof claim.owner === "string" ? claim.owner : undefined;
27
+ }
@@ -0,0 +1,91 @@
1
+ import type {
2
+ ExtensionAPI,
3
+ ExtensionContext,
4
+ RegisteredCommand,
5
+ } from "@earendil-works/pi-coding-agent";
6
+
7
+ import type { Control, Json } from "../control.ts";
8
+ import type { Setting, SettingsScope } from "../settings/setting.ts";
9
+ import type { SettingsStore } from "../settings/store.ts";
10
+
11
+ export type SessionHook = (ctx: ExtensionContext) => void | Promise<void>;
12
+ export type ShutdownHook = () => void | Promise<void>;
13
+ export type CommandOptions = Omit<RegisteredCommand, "name" | "sourceInfo">;
14
+
15
+ export interface Feature {
16
+ /** Unique across every package, like `subscription`. It names the feature's settings too. */
17
+ id: string;
18
+ description: string;
19
+ /** The settings tab. Features with the same tab share it. Defaults to the id. */
20
+ tab?: string;
21
+ /** Section order on the tab. Sections not listed come after. A function runs when the screen opens. */
22
+ sections?: readonly string[] | ((ctx: ExtensionContext | undefined) => readonly string[]);
23
+ settings?: readonly Setting<unknown>[];
24
+ setup(scope: FeatureScope): void;
25
+ }
26
+
27
+ /** Extends `ExtensionAPI`, since reading it through another type collapses pi's per-event overloads. */
28
+ export interface FeatureScope extends ExtensionAPI, SettingsScope {
29
+ readonly id: string;
30
+ readonly settings: SettingsStore;
31
+ onSessionStart(hook: SessionHook): void;
32
+ /** Runs once per session, newest first. */
33
+ onShutdown(hook: ShutdownHook): void;
34
+ /** Runs once after `ms`, never after the session ends; cancelled before any shutdown hook runs. */
35
+ defer(run: () => void, ms?: number): void;
36
+ /** Shown as `<app>: <feature>: <message>`. */
37
+ warn(message: string): void;
38
+ has(featureId: string): boolean;
39
+ readonly screen: ScreenRows;
40
+ }
41
+
42
+ interface ScreenRow {
43
+ id: string;
44
+ section: string;
45
+ label: string;
46
+ description: string;
47
+ /** How deep the row sits under its section. Each step indents two columns. */
48
+ indent?: number;
49
+ }
50
+
51
+ export interface ScreenValue extends ScreenRow {
52
+ control: Control | ((ctx: ExtensionContext) => Control);
53
+ get(ctx: ExtensionContext): Json;
54
+ /** Returns why the value was refused. */
55
+ set(value: Json, ctx: ExtensionContext): string | undefined;
56
+ /** Where the value applies, when that is not this session. */
57
+ meta?: string;
58
+ /** What the row shows in place of the value, like a count for a list. Undefined shows the value. */
59
+ text?(ctx: ExtensionContext): string | undefined;
60
+ }
61
+
62
+ export interface ScreenAction extends ScreenRow {
63
+ text?(ctx: ExtensionContext): string;
64
+ confirm?: string;
65
+ /** Returned text opens in the screen. A throw shows as an error. */
66
+ run(ctx: ExtensionContext): string | undefined | Promise<string | undefined>;
67
+ }
68
+
69
+ export interface ScreenInfo extends ScreenRow {
70
+ text(ctx: ExtensionContext): string;
71
+ }
72
+
73
+ export type ScreenEntry =
74
+ | ({ kind: "value" } & ScreenValue)
75
+ | ({ kind: "action" } & ScreenAction)
76
+ | ({ kind: "info" } & ScreenInfo);
77
+
78
+ /** Rows a feature builds when the screen opens, for what is only known then. */
79
+ export type ScreenGroup = (ctx: ExtensionContext) => ScreenEntry[];
80
+
81
+ export interface ScreenRows {
82
+ value(row: ScreenValue): void;
83
+ action(row: ScreenAction): void;
84
+ info(row: ScreenInfo): void;
85
+ /** One call, many rows: the provider runs every time the screen opens. */
86
+ rows(provider: ScreenGroup): void;
87
+ }
88
+
89
+ export function defineFeature(feature: Feature): Feature {
90
+ return feature;
91
+ }
@@ -0,0 +1,214 @@
1
+ // Each app answers the screen for the features it runs. A change goes through the setting's own
2
+ // decoder, like a value read from the file.
3
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
4
+
5
+ import {
6
+ APPLY,
7
+ type ApplyRequest,
8
+ DONE,
9
+ LIST,
10
+ type RowView,
11
+ RUN,
12
+ type RunDone,
13
+ type RunRequest,
14
+ type TabView,
15
+ } from "../contracts/screen.ts";
16
+ import type { Json } from "../control.ts";
17
+ import { formatProblems, isObject } from "../decode.ts";
18
+ import { controlOf, type Setting } from "../settings/setting.ts";
19
+ import type { SettingsStore } from "../settings/store.ts";
20
+ import type { Feature, ScreenEntry, ScreenGroup } from "./feature.ts";
21
+
22
+ export interface Host {
23
+ pi: ExtensionAPI;
24
+ settings: SettingsStore;
25
+ features: () => readonly Feature[];
26
+ screen: ReadonlyMap<string, ScreenEntry[]>;
27
+ screenGroups: ReadonlyMap<string, ScreenGroup[]>;
28
+ session: () => ExtensionContext | undefined;
29
+ isLive: () => boolean;
30
+ }
31
+
32
+ export function serveScreen(host: Host): void {
33
+ host.pi.events.on(LIST, (data) => {
34
+ if (!host.isLive() || !isObject(data) || !Array.isArray(data.tabs)) return;
35
+ const ctx = host.session();
36
+ for (const feature of host.features()) data.tabs.push(tabOf(host, feature, ctx));
37
+ });
38
+
39
+ host.pi.events.on(APPLY, (data) => {
40
+ const request = asApply(data);
41
+ if (request === undefined || !host.isLive()) return;
42
+ const feature = host.features().find((entry) => entry.id === request.feature);
43
+ if (feature === undefined) return;
44
+ request.answer = { error: apply(host, feature, request) };
45
+ });
46
+
47
+ host.pi.events.on(RUN, (data) => {
48
+ const request = asRun(data);
49
+ if (request === undefined || !host.isLive()) return;
50
+ if (!host.features().some((entry) => entry.id === request.feature)) return;
51
+ const ctx = host.session();
52
+ const row = findRow(host, request.feature, request.id, ctx);
53
+ if (row === undefined) return;
54
+
55
+ if (row.kind !== "action" || ctx === undefined) {
56
+ request.answer = { error: "nothing to run" };
57
+ return;
58
+ }
59
+ request.answer = {};
60
+ const id = request.request;
61
+ void runAction(row, ctx).then((done) => host.pi.events.emit(DONE, { request: id, ...done }));
62
+ });
63
+ }
64
+
65
+ function tabOf(host: Host, feature: Feature, ctx: ExtensionContext | undefined): TabView {
66
+ const rows: RowView[] = [];
67
+ for (const entry of feature.settings ?? []) {
68
+ const row = settingRow(host.settings, feature, entry, ctx);
69
+ if (row !== undefined) rows.push(row);
70
+ }
71
+ if (ctx !== undefined) {
72
+ for (const entry of entriesOf(host, feature.id, ctx)) rows.push(screenRow(feature, entry, ctx));
73
+ }
74
+
75
+ const listed = typeof feature.sections === "function" ? feature.sections(ctx) : feature.sections;
76
+ const sections = [...(listed ?? [])];
77
+ for (const row of rows) if (!sections.includes(row.section)) sections.push(row.section);
78
+ return { title: feature.tab ?? capitalize(feature.id), sections, rows };
79
+ }
80
+
81
+ function settingRow(
82
+ settings: SettingsStore,
83
+ feature: Feature,
84
+ entry: Setting<unknown>,
85
+ ctx: ExtensionContext | undefined,
86
+ ): RowView | undefined {
87
+ const ui = entry.ui;
88
+ if (ui === undefined) return undefined;
89
+
90
+ const control = controlOf(entry, ctx);
91
+ const value = toJson(settings.get(entry));
92
+ const layer = settings.layer(entry);
93
+ const row: RowView = {
94
+ feature: feature.id,
95
+ id: entry.id,
96
+ kind: control === undefined ? "info" : "setting",
97
+ section: ui.section,
98
+ label: ui.label,
99
+ description: ui.description,
100
+ value,
101
+ layer,
102
+ fallback: toJson(entry.default),
103
+ };
104
+ if (control === undefined) row.text = JSON.stringify(value);
105
+ else row.control = control;
106
+ if (layer === "project") row.hidden = toJson(settings.hidden(entry));
107
+ if (ui.restart === true) row.restart = true;
108
+ if (ui.preview !== undefined) row.preview = ui.preview;
109
+ return row;
110
+ }
111
+
112
+ function screenRow(feature: Feature, entry: ScreenEntry, ctx: ExtensionContext): RowView {
113
+ const row: RowView = {
114
+ feature: feature.id,
115
+ id: entry.id,
116
+ kind: entry.kind,
117
+ section: entry.section,
118
+ label: entry.label,
119
+ description: entry.description,
120
+ };
121
+ if (entry.indent !== undefined) row.indent = entry.indent;
122
+ switch (entry.kind) {
123
+ case "value":
124
+ row.control = typeof entry.control === "function" ? entry.control(ctx) : entry.control;
125
+ row.value = entry.get(ctx);
126
+ if (entry.meta !== undefined) row.meta = entry.meta;
127
+ const shown = entry.text?.(ctx);
128
+ if (shown !== undefined) row.text = shown;
129
+ break;
130
+ case "action": {
131
+ const text = entry.text?.(ctx);
132
+ if (text !== undefined) row.text = text;
133
+ if (entry.confirm !== undefined) row.confirm = entry.confirm;
134
+ break;
135
+ }
136
+ case "info":
137
+ row.text = entry.text(ctx);
138
+ break;
139
+ }
140
+ return row;
141
+ }
142
+
143
+ function apply(host: Host, feature: Feature, request: ApplyRequest): string | undefined {
144
+ const entry = feature.settings?.find((candidate) => candidate.id === request.id);
145
+ if (entry !== undefined) {
146
+ if (request.op === "unset") {
147
+ const layer = host.settings.layer(entry);
148
+ return layer === "default" ? undefined : host.settings.unset(entry, layer);
149
+ }
150
+ const decoded = entry.decoder.decode(request.value, entry.id);
151
+ if (!decoded.ok) return formatProblems(decoded.problems).join("; ");
152
+ return host.settings.set(entry, decoded.value);
153
+ }
154
+
155
+ const ctx = host.session();
156
+ const row = findRow(host, feature.id, request.id, ctx);
157
+ if (row?.kind !== "value" || ctx === undefined) return "this row cannot change";
158
+ if (request.op === "unset") return "this row has no default to go back to";
159
+ return row.set(request.value ?? null, ctx);
160
+ }
161
+
162
+ function entriesOf(host: Host, feature: string, ctx: ExtensionContext): ScreenEntry[] {
163
+ return [
164
+ ...(host.screen.get(feature) ?? []),
165
+ ...(host.screenGroups.get(feature) ?? []).flatMap((group) => group(ctx)),
166
+ ];
167
+ }
168
+
169
+ /** An action can sit on a built row too, so the lookup walks the same two places. */
170
+ function findRow(
171
+ host: Host,
172
+ feature: string,
173
+ id: string,
174
+ ctx: ExtensionContext | undefined,
175
+ ): ScreenEntry | undefined {
176
+ if (ctx === undefined) return host.screen.get(feature)?.find((candidate) => candidate.id === id);
177
+ return entriesOf(host, feature, ctx).find((candidate) => candidate.id === id);
178
+ }
179
+
180
+ async function runAction(
181
+ row: Extract<ScreenEntry, { kind: "action" }>,
182
+ ctx: ExtensionContext,
183
+ ): Promise<Omit<RunDone, "request">> {
184
+ try {
185
+ const text = await row.run(ctx);
186
+ return text === undefined ? {} : { text };
187
+ } catch (error) {
188
+ return { error: error instanceof Error ? error.message : String(error) };
189
+ }
190
+ }
191
+
192
+ function asApply(data: unknown): ApplyRequest | undefined {
193
+ if (!isObject(data) || data.answer !== undefined) return undefined;
194
+ const { feature, id, op } = data;
195
+ if (typeof feature !== "string" || typeof id !== "string") return undefined;
196
+ if (op !== "set" && op !== "unset") return undefined;
197
+ return data as unknown as ApplyRequest;
198
+ }
199
+
200
+ function asRun(data: unknown): RunRequest | undefined {
201
+ if (!isObject(data) || data.answer !== undefined) return undefined;
202
+ const { feature, id, request } = data;
203
+ if (typeof feature !== "string" || typeof id !== "string" || typeof request !== "string")
204
+ return undefined;
205
+ return data as unknown as RunRequest;
206
+ }
207
+
208
+ function toJson(value: unknown): Json {
209
+ return value === undefined ? null : (JSON.parse(JSON.stringify(value)) as Json);
210
+ }
211
+
212
+ function capitalize(text: string): string {
213
+ return text.charAt(0).toUpperCase() + text.slice(1);
214
+ }
@@ -0,0 +1,62 @@
1
+ // The renderers the harness's features register, kept so a view can draw another session's entries
2
+ // the way pi draws this one's. Pi resolves its own chain but gives extensions no way to call it.
3
+ import type {
4
+ MessageRenderer,
5
+ ToolRendererResolver,
6
+ ToolRenderers,
7
+ } from "@earendil-works/pi-coding-agent";
8
+
9
+ interface Registered {
10
+ tools: ToolRendererResolver[];
11
+ messages: Map<string, MessageRenderer>;
12
+ }
13
+
14
+ // By feature, in the order they mount, which is the order pi resolves them in. A feature mounted again
15
+ // for the next session replaces what it registered for the last one.
16
+ const byFeature = new Map<string, Registered>();
17
+
18
+ export function forgetRenderers(feature: string): void {
19
+ byFeature.delete(feature);
20
+ }
21
+
22
+ export function keepToolRenderer(feature: string, resolver: ToolRendererResolver): void {
23
+ registered(feature).tools.push(resolver);
24
+ }
25
+
26
+ export function keepMessageRenderer(
27
+ feature: string,
28
+ customType: string,
29
+ renderer: MessageRenderer,
30
+ ): void {
31
+ registered(feature).messages.set(customType, renderer);
32
+ }
33
+
34
+ /** As pi resolves them: every feature's resolver over `base`. */
35
+ export function resolveToolRenderers(
36
+ toolName: string,
37
+ base: () => ToolRenderers | undefined,
38
+ ): ToolRenderers | undefined {
39
+ const resolvers = [...byFeature.values()].flatMap((one) => one.tools);
40
+ function resolve(index: number): ToolRenderers | undefined {
41
+ const resolver = resolvers[index];
42
+ return resolver === undefined ? base() : resolver(toolName, () => resolve(index + 1));
43
+ }
44
+ return resolve(0);
45
+ }
46
+
47
+ export function messageRendererFor(customType: string): MessageRenderer | undefined {
48
+ for (const one of byFeature.values()) {
49
+ const renderer = one.messages.get(customType);
50
+ if (renderer !== undefined) return renderer;
51
+ }
52
+ return undefined;
53
+ }
54
+
55
+ function registered(feature: string): Registered {
56
+ let one = byFeature.get(feature);
57
+ if (one === undefined) {
58
+ one = { tools: [], messages: new Map() };
59
+ byFeature.set(feature, one);
60
+ }
61
+ return one;
62
+ }