@kontextmind/kxm 0.7.40 → 0.7.44

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 (178) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +45 -7
  3. package/docs/README.md +5 -3
  4. package/docs/agent-skills.md +1 -1
  5. package/docs/architecture.md +1 -1
  6. package/docs/configuration.md +28 -5
  7. package/docs/{vnext → contracts}/README.md +7 -7
  8. package/docs/{vnext → contracts}/architecture.md +2 -2
  9. package/docs/{vnext → contracts}/migration.md +25 -25
  10. package/docs/{vnext → contracts}/routing.md +2 -2
  11. package/docs/{vnext → contracts}/synchronization.md +1 -1
  12. package/docs/{vnext → contracts}/validation.md +7 -7
  13. package/docs/getting-started.md +1 -1
  14. package/docs/kb/qa-authentik-authentication.md +47 -0
  15. package/docs/kb/qa-extension-install-and-hub-bootstrap.md +68 -0
  16. package/docs/kb/qa-hub-on-a-public-host.md +31 -0
  17. package/docs/kb/qa-sqlite-vs-duckdb.md +18 -0
  18. package/docs/kb/qa-what-the-hub-stores.md +47 -0
  19. package/docs/packages.md +91 -0
  20. package/docs/templates/architecture.md +1 -1
  21. package/docs/test-matrix.md +6 -6
  22. package/docs/tui-components.md +131 -0
  23. package/examples/README.md +2 -2
  24. package/examples/{vnext → project}/.kxm/agents/critic-3.yaml +1 -1
  25. package/examples/{vnext → project}/README.md +4 -4
  26. package/package.json +18 -8
  27. package/packages/core/tui/CHANGELOG.md +19 -0
  28. package/packages/core/tui/LICENSE +21 -0
  29. package/packages/core/tui/README.md +45 -0
  30. package/packages/core/tui/dist/index.js +1439 -0
  31. package/packages/core/tui/src/adapters/pi.ts +73 -0
  32. package/packages/core/tui/src/adapters/terminal.ts +106 -0
  33. package/packages/core/tui/src/exports/index.ts +30 -0
  34. package/packages/core/tui/src/services/registry.ts +274 -0
  35. package/packages/core/tui/src/tui/keys.ts +130 -0
  36. package/packages/core/tui/src/tui/layout.ts +117 -0
  37. package/packages/core/tui/src/tui/panel.ts +435 -0
  38. package/packages/core/tui/src/tui/panelComponent.ts +208 -0
  39. package/packages/core/tui/src/tui/render.ts +309 -0
  40. package/packages/core/tui/src/tui/theme.ts +145 -0
  41. package/packages/core/tui/src/types/surface.ts +407 -0
  42. package/plugins/kxm/.claude-plugin/plugin.json +1 -1
  43. package/plugins/kxm/dist/cli.js +681 -553
  44. package/plugins/kxm/dist/extension.js +18 -18
  45. package/plugins/kxm/dist/mcp-server.js +1 -1
  46. package/plugins/kxm/dist/{vnext-runtime-supervisor.js → runtime-supervisor.js} +553 -553
  47. package/plugins/kxm/dist/runtime.js +608 -608
  48. package/plugins/kxm/dist/server.js +13 -13
  49. package/plugins/kxm/package.json +2 -2
  50. package/plugins/kxm/skills/kxm-project-setup/SKILL.md +2 -2
  51. package/plugins/kxm/skills/kxm-runs/SKILL.md +3 -3
  52. package/plugins/kxm/skills/kxm-session/SKILL.md +1 -1
  53. package/plugins/kxm/skills/kxm-workflow/SKILL.md +1 -1
  54. package/plugins/kxm/src/autocomplete.ts +4 -4
  55. package/plugins/kxm/src/{vnext-bindings.ts → bindings.ts} +48 -48
  56. package/plugins/kxm/src/cli/hub.ts +3 -3
  57. package/plugins/kxm/src/cli/{vnext.ts → project.ts} +106 -106
  58. package/plugins/kxm/src/cli/roles.ts +9 -9
  59. package/plugins/kxm/src/cli/system.ts +2 -2
  60. package/plugins/kxm/src/cli/tasks.ts +8 -8
  61. package/plugins/kxm/src/cli/workflows.ts +8 -8
  62. package/plugins/kxm/src/cli.ts +47 -47
  63. package/plugins/kxm/src/config.ts +69 -14
  64. package/plugins/kxm/src/database.ts +4 -4
  65. package/plugins/kxm/src/{vnext-engine-artifacts.ts → engine-artifacts.ts} +7 -7
  66. package/plugins/kxm/src/{vnext-engine-command.ts → engine-command.ts} +40 -40
  67. package/plugins/kxm/src/{vnext-engine-compile.ts → engine-compile.ts} +64 -64
  68. package/plugins/kxm/src/{vnext-engine-evidence.ts → engine-evidence.ts} +26 -26
  69. package/plugins/kxm/src/{vnext-engine-fold.ts → engine-fold.ts} +99 -99
  70. package/plugins/kxm/src/{vnext-engine-gate-records.ts → engine-gate-records.ts} +105 -105
  71. package/plugins/kxm/src/{vnext-engine-plan.ts → engine-plan.ts} +69 -69
  72. package/plugins/kxm/src/{vnext-engine.ts → engine.ts} +502 -502
  73. package/plugins/kxm/src/gate-hash.ts +10 -0
  74. package/plugins/kxm/src/{vnext-harness.ts → harness.ts} +1 -1
  75. package/plugins/kxm/src/hub.ts +1 -1
  76. package/plugins/kxm/src/init-guide-setup.ts +3 -3
  77. package/plugins/kxm/src/{vnext-init.ts → init.ts} +93 -93
  78. package/plugins/kxm/src/kxm-install-kind.ts +1 -1
  79. package/plugins/kxm/src/kxm-update-config.ts +2 -2
  80. package/plugins/kxm/src/local-snapshot.ts +23 -23
  81. package/plugins/kxm/src/mcp-server.ts +1 -1
  82. package/plugins/kxm/src/{vnext-migrate.ts → migrate.ts} +123 -123
  83. package/plugins/kxm/src/model-inventory.ts +1 -1
  84. package/plugins/kxm/src/{vnext-oneshot-evidence.ts → oneshot-evidence.ts} +2 -2
  85. package/plugins/kxm/src/{vnext-oneshot-process.ts → oneshot-process.ts} +4 -4
  86. package/plugins/kxm/src/{vnext-oneshot-producer.ts → oneshot-producer.ts} +24 -24
  87. package/plugins/kxm/src/{vnext-permission.ts → permission.ts} +86 -86
  88. package/plugins/kxm/src/{vnext-pi-producer.ts → pi-producer.ts} +13 -13
  89. package/plugins/kxm/src/{vnext-config.ts → project-config.ts} +177 -177
  90. package/plugins/kxm/src/{vnext-repair.ts → repair.ts} +159 -159
  91. package/plugins/kxm/src/restricted-yaml.d.mts +3 -3
  92. package/plugins/kxm/src/restricted-yaml.mjs +4 -4
  93. package/plugins/kxm/src/{vnext-runtime-owner.ts → runtime-owner.ts} +35 -35
  94. package/plugins/kxm/src/{vnext-runtime.ts → runtime-service.ts} +136 -136
  95. package/plugins/kxm/src/{vnext-runtime-store.ts → runtime-store.ts} +145 -145
  96. package/plugins/kxm/src/{vnext-runtime-supervisor.ts → runtime-supervisor.ts} +80 -80
  97. package/plugins/kxm/src/runtime.ts +6 -6
  98. package/plugins/kxm/src/session-work.ts +3 -3
  99. package/plugins/kxm/src/studio-layout.ts +6 -6
  100. package/plugins/kxm/src/{vnext-template.ts → template.ts} +33 -33
  101. package/plugins/kxm/src/tui.ts +10 -24
  102. package/schemas/{vnext/README.md → README.md} +3 -3
  103. package/schemas/{vnext/agent.schema.json → agent.schema.json} +1 -1
  104. package/schemas/{vnext/assignment-result.schema.json → assignment-result.schema.json} +1 -1
  105. package/schemas/{vnext/backup-manifest.schema.json → backup-manifest.schema.json} +1 -1
  106. package/schemas/{vnext/candidate.schema.json → candidate.schema.json} +1 -1
  107. package/schemas/{vnext/common.schema.json → common.schema.json} +2 -2
  108. package/schemas/{vnext/context-candidate.schema.json → context-candidate.schema.json} +1 -1
  109. package/schemas/{vnext/context-packet.schema.json → context-packet.schema.json} +1 -1
  110. package/schemas/{vnext/delivery-manifest.schema.json → delivery-manifest.schema.json} +1 -1
  111. package/schemas/{vnext/drive-receipt.schema.json → drive-receipt.schema.json} +2 -2
  112. package/schemas/{vnext/environment.schema.json → environment.schema.json} +1 -1
  113. package/schemas/{vnext/gate-registry.schema.json → gate-registry.schema.json} +1 -1
  114. package/schemas/{vnext/handoff-manifest.schema.json → handoff-manifest.schema.json} +1 -1
  115. package/schemas/{vnext/init-operation.schema.json → init-operation.schema.json} +1 -1
  116. package/schemas/{vnext/local-repository-bindings.schema.json → local-repository-bindings.schema.json} +1 -1
  117. package/schemas/{vnext/memory-record.schema.json → memory-record.schema.json} +1 -1
  118. package/schemas/{vnext/migration-decision.schema.json → migration-decision.schema.json} +1 -1
  119. package/schemas/{vnext/migration-plan.schema.json → migration-plan.schema.json} +1 -1
  120. package/schemas/{vnext/migration-receipt.schema.json → migration-receipt.schema.json} +1 -1
  121. package/schemas/{vnext/model.schema.json → model.schema.json} +1 -1
  122. package/schemas/{vnext/modes.schema.json → modes.schema.json} +1 -1
  123. package/schemas/{vnext/permission-diff.schema.json → permission-diff.schema.json} +1 -1
  124. package/schemas/policy-draft/README.md +2 -2
  125. package/schemas/{vnext/prices.schema.json → prices.schema.json} +1 -1
  126. package/schemas/{vnext/project.schema.json → project.schema.json} +1 -1
  127. package/schemas/{vnext/repository.schema.json → repository.schema.json} +1 -1
  128. package/schemas/{vnext/role.schema.json → role.schema.json} +1 -1
  129. package/schemas/{vnext/run-event.schema.json → run-event.schema.json} +1 -1
  130. package/schemas/{vnext/session-brief.schema.json → session-brief.schema.json} +2 -2
  131. package/schemas/{vnext/sync-event.schema.json → sync-event.schema.json} +1 -1
  132. package/schemas/{vnext/template-provenance.schema.json → template-provenance.schema.json} +1 -1
  133. package/schemas/{vnext/workflow.schema.json → workflow.schema.json} +1 -1
  134. package/scripts/build-package.mjs +59 -0
  135. package/scripts/build-runtime.mjs +3 -2
  136. package/scripts/check-generated.mjs +2 -1
  137. package/scripts/check-versions.mjs +12 -1
  138. package/scripts/docker-install-smoke.mjs +268 -0
  139. package/scripts/emit-codex-artifacts.mjs +1 -1
  140. package/scripts/kxm-bump-version.mjs +53 -3
  141. package/scripts/kxm-hub.mjs +36 -0
  142. package/scripts/kxm-runtime-supervisor.mjs +3 -3
  143. package/scripts/package-surfaces.mjs +39 -0
  144. package/plugins/kxm/src/vnext-gate-hash.ts +0 -10
  145. /package/docs/{vnext → contracts}/effects-and-recovery.md +0 -0
  146. /package/docs/{vnext → contracts}/lifecycles.md +0 -0
  147. /package/docs/{vnext → contracts}/terminology.md +0 -0
  148. /package/examples/{vnext → project}/.kxm/agents/coordinator.yaml +0 -0
  149. /package/examples/{vnext → project}/.kxm/agents/critic-1.yaml +0 -0
  150. /package/examples/{vnext → project}/.kxm/agents/critic-2.yaml +0 -0
  151. /package/examples/{vnext → project}/.kxm/agents/implementer.yaml +0 -0
  152. /package/examples/{vnext → project}/.kxm/agents/planner.yaml +0 -0
  153. /package/examples/{vnext → project}/.kxm/agents/reproducer.yaml +0 -0
  154. /package/examples/{vnext → project}/.kxm/agents/reviewer.yaml +0 -0
  155. /package/examples/{vnext → project}/.kxm/gates.yaml +0 -0
  156. /package/examples/{vnext → project}/.kxm/models/critic-claude.yaml +0 -0
  157. /package/examples/{vnext → project}/.kxm/models/critic-gemini.yaml +0 -0
  158. /package/examples/{vnext → project}/.kxm/models/critic-grok.yaml +0 -0
  159. /package/examples/{vnext → project}/.kxm/models/implementation.yaml +0 -0
  160. /package/examples/{vnext → project}/.kxm/models/primary.yaml +0 -0
  161. /package/examples/{vnext → project}/.kxm/prices.yaml +0 -0
  162. /package/examples/{vnext → project}/.kxm/project/env.yaml +0 -0
  163. /package/examples/{vnext → project}/.kxm/project.yaml +0 -0
  164. /package/examples/{vnext → project}/.kxm/repo/repo.yaml +0 -0
  165. /package/examples/{vnext → project}/.kxm/workflows/default.yaml +0 -0
  166. /package/examples/{vnext → project}/.kxm/workflows/fix.yaml +0 -0
  167. /package/examples/{vnext → project}/.kxm/workflows/improve.yaml +0 -0
  168. /package/examples/{vnext → project}/records/assignment-result-recorded.json +0 -0
  169. /package/examples/{vnext → project}/records/assignment-result.json +0 -0
  170. /package/examples/{vnext → project}/records/context-candidate.json +0 -0
  171. /package/examples/{vnext → project}/records/delivery-manifest.json +0 -0
  172. /package/examples/{vnext → project}/records/effect-uncertainty-resolved-sync.json +0 -0
  173. /package/examples/{vnext → project}/records/effect-uncertainty-resolved.json +0 -0
  174. /package/examples/{vnext → project}/records/run-created.json +0 -0
  175. /package/examples/{vnext → project}/records/sync-event.json +0 -0
  176. /package/examples/{vnext → project}/repositories/api/.kxm/repo/env.yaml +0 -0
  177. /package/examples/{vnext → project}/repositories/api/.kxm/repo/repo.yaml +0 -0
  178. /package/examples/{vnext → project}/repositories/web/.kxm/repo/repo.yaml +0 -0
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Pi TUI adapter.
3
+ *
4
+ * A Pi extension opens a surface with `ctx.ui.custom((tui, theme, keybindings,
5
+ * done) => component)`. This binds the kit's panel to that shape: the host's
6
+ * theme supplies colours, the host's renderer is asked to repaint after every
7
+ * key, and closing the panel hands back the owner's last published value.
8
+ *
9
+ * Nothing here knows what a roster, a route, or a gate is. The registry does,
10
+ * which is why the same adapter serves every KXM surface.
11
+ */
12
+
13
+ import { KxmTuiPanelComponent } from "../tui/panelComponent.ts";
14
+ import type { KxmTuiRegistry } from "../services/registry.ts";
15
+ import { createKxmTuiThemeFromPi, type PiThemeLike } from "../tui/theme.ts";
16
+
17
+ /** The renderer handle Pi passes to a custom component. */
18
+ export interface KxmTuiCustomTui {
19
+ requestRender(): void;
20
+ }
21
+
22
+ /** The component shape Pi's `ctx.ui.custom` consumes. */
23
+ export interface KxmTuiCustomComponent {
24
+ render(width: number): string[];
25
+ handleInput(data: string): void;
26
+ invalidate(): void;
27
+ }
28
+
29
+ export interface KxmTuiPiPanelOptions {
30
+ readonly registry: KxmTuiRegistry;
31
+ readonly tui: KxmTuiCustomTui;
32
+ readonly theme: PiThemeLike;
33
+ readonly title: string;
34
+ readonly breadcrumb?: string;
35
+ readonly helpLines?: readonly string[];
36
+ readonly width?: () => number;
37
+ /** Called once when the operator leaves the panel. */
38
+ readonly done?: (closed: true) => void;
39
+ }
40
+
41
+ /**
42
+ * Build the object to return from `ctx.ui.custom`.
43
+ *
44
+ * The panel is disposed on close, so a reopened surface must be built again
45
+ * rather than reused: Pi drops a custom component when it is closed.
46
+ */
47
+ export function createKxmTuiPiPanel(options: KxmTuiPiPanelOptions): KxmTuiCustomComponent {
48
+ let closed = false;
49
+ const panel = new KxmTuiPanelComponent({
50
+ registry: options.registry,
51
+ theme: createKxmTuiThemeFromPi(options.theme),
52
+ title: options.title,
53
+ ...(options.breadcrumb === undefined ? {} : { breadcrumb: options.breadcrumb }),
54
+ ...(options.helpLines === undefined ? {} : { helpLines: options.helpLines }),
55
+ requestRender: () => options.tui.requestRender(),
56
+ onQuit: () => {
57
+ if (closed) return;
58
+ closed = true;
59
+ panel.dispose();
60
+ options.done?.(true);
61
+ },
62
+ ...(options.width === undefined ? {} : { getWidth: options.width }),
63
+ });
64
+ return {
65
+ render: (width) => panel.render(width),
66
+ handleInput: (data) => {
67
+ if (closed) return;
68
+ panel.handleInput(data);
69
+ options.tui.requestRender();
70
+ },
71
+ invalidate: () => panel.invalidate(),
72
+ };
73
+ }
@@ -0,0 +1,106 @@
1
+ /**
2
+ * Standalone driver for a KXM panel surface.
3
+ *
4
+ * `kxm dash` already owns its hub and SSE loop, so the kit does not duplicate
5
+ * it. This is the small alternative for a local, file-backed surface — a config
6
+ * or roster panel that repaints when an owner republishes and stops when the
7
+ * operator leaves.
8
+ *
9
+ * A non-TTY caller gets one plain-text frame and exit code zero, which keeps
10
+ * `--dry-run`, CI smoke, and piped output honest instead of pretending to be an
11
+ * interactive session.
12
+ */
13
+
14
+ import { ProcessTerminal, TuiAltScreen, stripTerminalSequences, type Terminal } from "@earendil-works/pi-tui";
15
+ import { KxmTuiPanelComponent } from "../tui/panelComponent.ts";
16
+ import { initialKxmTuiPanelState } from "../tui/panel.ts";
17
+ import { renderKxmTuiPanel } from "../tui/render.ts";
18
+ import type { KxmTuiRegistry } from "../services/registry.ts";
19
+ import { createDefaultKxmTuiTheme, createPlainKxmTuiTheme, type KxmTuiTheme } from "../tui/theme.ts";
20
+
21
+ export interface RunKxmTuiPanelInput {
22
+ readonly registry: KxmTuiRegistry;
23
+ readonly title: string;
24
+ readonly breadcrumb?: string;
25
+ readonly helpLines?: readonly string[];
26
+ readonly stdout: (text: string) => void;
27
+ readonly terminal?: Terminal;
28
+ readonly isTty?: boolean;
29
+ readonly theme?: KxmTuiTheme;
30
+ readonly abort?: AbortSignal;
31
+ /** Repaint interval for surfaces whose owners can change outside this process. */
32
+ readonly pollMs?: number;
33
+ readonly now?: () => number;
34
+ }
35
+
36
+ /** Draw one frame without a terminal. Used by `--dry-run` and CI smoke. */
37
+ export function renderKxmTuiPanelFrame(input: {
38
+ registry: KxmTuiRegistry;
39
+ title: string;
40
+ theme?: KxmTuiTheme;
41
+ width?: number;
42
+ nowMs?: number;
43
+ footer?: string;
44
+ }): string {
45
+ const theme = input.theme ?? createPlainKxmTuiTheme();
46
+ const lines = renderKxmTuiPanel(
47
+ input.registry.getSections(),
48
+ initialKxmTuiPanelState(input.registry.getSections()),
49
+ theme,
50
+ { title: input.title, footer: input.footer ?? "non-interactive frame", nowMs: input.nowMs ?? Date.now() },
51
+ input.width ?? 100,
52
+ );
53
+ return `${lines.map((line) => stripTerminalSequences(line)).join("\n")}\n`;
54
+ }
55
+
56
+ /** Run a panel until it quits, the caller aborts, or one frame is enough. */
57
+ export async function runKxmTuiPanel(input: RunKxmTuiPanelInput): Promise<number> {
58
+ const theme = input.theme ?? createDefaultKxmTuiTheme();
59
+ const tty = input.isTty ?? Boolean(process.stdin.isTTY && process.stdout.isTTY);
60
+ if (!tty) {
61
+ input.stdout(renderKxmTuiPanelFrame({
62
+ registry: input.registry,
63
+ title: input.title,
64
+ theme,
65
+ ...(input.now === undefined ? {} : { nowMs: input.now() }),
66
+ ...(input.breadcrumb ? { footer: input.breadcrumb } : {}),
67
+ }));
68
+ return 0;
69
+ }
70
+
71
+ const terminal = input.terminal ?? new ProcessTerminal();
72
+ const tui = new TuiAltScreen(terminal, false, undefined, { mouse: true });
73
+ let timer: ReturnType<typeof setInterval> | undefined;
74
+ let stopped = false;
75
+ let finished: () => void = () => undefined;
76
+ const done = new Promise<void>((resolve) => {
77
+ finished = resolve;
78
+ });
79
+ const stop = (): void => {
80
+ if (stopped) return;
81
+ stopped = true;
82
+ if (timer) clearInterval(timer);
83
+ input.abort?.removeEventListener("abort", stop);
84
+ panel.dispose();
85
+ tui.stop();
86
+ finished();
87
+ };
88
+ const panel = new KxmTuiPanelComponent({
89
+ registry: input.registry,
90
+ theme,
91
+ title: input.title,
92
+ ...(input.breadcrumb ? { breadcrumb: input.breadcrumb } : {}),
93
+ ...(input.helpLines ? { helpLines: input.helpLines } : {}),
94
+ requestRender: () => tui.requestRender(),
95
+ onQuit: stop,
96
+ getWidth: () => terminal.columns,
97
+ });
98
+ input.abort?.addEventListener("abort", stop, { once: true });
99
+ tui.setLayoutRoot(panel.root);
100
+ tui.setFocus(panel);
101
+ tui.start();
102
+ timer = setInterval(() => panel.refresh(), input.pollMs ?? 2_000);
103
+ timer.unref?.();
104
+ await done;
105
+ return stopped && input.abort?.aborted ? 130 : 0;
106
+ }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Public surface of `@kontextmind/tui`.
3
+ *
4
+ * One declarative surface model, one renderer, one input decoder, one
5
+ * contribution registry, and thin host adapters, so `kxm dash`, the
6
+ * configuration surfaces, and any host adapter draw the same controls and fail
7
+ * the same way.
8
+ *
9
+ * Layers:
10
+ * - `types/` the published-surface contract and its validation
11
+ * - `tui/` pure controls: input decoding, layout, theme, reducer, renderer
12
+ * - `services/` the registry that routes every write to its owner
13
+ * - `adapters/` host bindings: a standalone terminal session and the Pi TUI
14
+ * - `exports/` what leaves the package; anything unexported may be reshaped
15
+ *
16
+ * Import discipline: this entry is Pi-bound because the components render with
17
+ * `@earendil-works/pi-tui`. It is never re-exported from
18
+ * `@kontextmind/kxm/core`, whose closure must stay free of Pi and commander.
19
+ */
20
+
21
+ export * from "../types/surface.ts";
22
+ export * from "../tui/keys.ts";
23
+ export * from "../tui/layout.ts";
24
+ export * from "../tui/theme.ts";
25
+ export * from "../tui/panel.ts";
26
+ export * from "../tui/render.ts";
27
+ export * from "../tui/panelComponent.ts";
28
+ export * from "../services/registry.ts";
29
+ export * from "../adapters/terminal.ts";
30
+ export * from "../adapters/pi.ts";
@@ -0,0 +1,274 @@
1
+ /**
2
+ * Surface registry: who owns which part of a KXM terminal surface.
3
+ *
4
+ * A panel is published, not centralised. Each owner (project, roster, routes,
5
+ * gates, roles, workflow, config) contributes its own sections and performs its
6
+ * own writes; the registry only routes requests and re-reads what changed. That
7
+ * keeps one rule true everywhere in KXM: the panel never becomes a second
8
+ * source of truth for configuration, and never writes a file by itself.
9
+ *
10
+ * There is no local echo. `invoke()` calls the owner and then re-reads its
11
+ * sections, so a rejected write comes back as the old value plus the owner's
12
+ * reason on `statusText`. A value the panel shows is a value that is on disk.
13
+ *
14
+ * Owners are addressed by name. A handler for an unregistered source, an
15
+ * unknown section, or an unknown action is refused rather than guessed, and a
16
+ * handler that throws never aborts the panel: the failure becomes a notice.
17
+ */
18
+
19
+ import {
20
+ KXM_TUI_LIMITS,
21
+ assertKxmTuiSurface,
22
+ validateKxmTuiSurface,
23
+ type KxmTuiInvocation,
24
+ type KxmTuiIssue,
25
+ type KxmTuiSectionView,
26
+ } from "../types/surface.ts";
27
+
28
+ export interface KxmTuiActionInput {
29
+ readonly sectionId: string;
30
+ readonly fieldId: string;
31
+ readonly action: string;
32
+ readonly value?: string;
33
+ /** Owner-scoped state the handler needs to resolve the current surface. */
34
+ readonly state?: unknown;
35
+ }
36
+
37
+ export interface KxmTuiContribution<TState = unknown> {
38
+ /** Addressable owner id, e.g. `kxm/roster`. It is audited, never display-only. */
39
+ readonly source: string;
40
+ /**
41
+ * Read at publish time, not cached: an owner may have just written a file,
42
+ * refreshed a catalog, or lost authentication, and the panel must show that.
43
+ */
44
+ readonly listSections: (state?: TState) => readonly KxmTuiSectionView[];
45
+ readonly handlers: Readonly<Record<string, (input: KxmTuiActionInput) => void | Promise<void>>>;
46
+ /** Turn an owner failure into a legible line. Defaults to `error.message`. */
47
+ readonly describeError?: (error: unknown, action: string) => string;
48
+ /**
49
+ * Whether a write must be re-read through `listSections` after the handler
50
+ * resolves. Always true here; declared so an owner cannot promise otherwise.
51
+ */
52
+ readonly republishAfterWrite?: true;
53
+ }
54
+
55
+ export interface KxmTuiRegistryEvent {
56
+ readonly reason: "publish" | "invoke" | "refused" | "failed";
57
+ readonly source?: string;
58
+ readonly message?: string;
59
+ }
60
+
61
+ export interface KxmTuiInvokeResult {
62
+ readonly ok: boolean;
63
+ /** Why the request was refused or failed, already fit for one line. */
64
+ readonly message?: string;
65
+ /** Contract violations that made the owner's published surface unusable. */
66
+ readonly issues?: readonly KxmTuiIssue[];
67
+ }
68
+
69
+ export interface KxmTuiRegistry {
70
+ /** Register an owner. Re-registering the same source replaces it. */
71
+ register(contribution: KxmTuiContribution): void;
72
+ unregister(source: string): void;
73
+ /** The merged surface, ordered by `order` then source, safe to render. */
74
+ getSections(): readonly KxmTuiSectionView[];
75
+ /** Sections as published, including any that failed validation. */
76
+ getIssues(): readonly KxmTuiIssue[];
77
+ invoke(invocation: KxmTuiInvocation): Promise<KxmTuiInvokeResult>;
78
+ subscribe(listener: (event: KxmTuiRegistryEvent) => void): () => void;
79
+ /** Monotonic token identifying the current session of this registry. */
80
+ getGeneration(): number;
81
+ /** A source is only present when it registered; used to gate tabs. */
82
+ hasSource(source: string): boolean;
83
+ listSources(): string[];
84
+ dispose(): void;
85
+ }
86
+
87
+ function describe(error: unknown): string {
88
+ if (error instanceof Error && error.message) return error.message.split(/\r?\n/u)[0]!.slice(0, KXM_TUI_LIMITS.detailMax);
89
+ return String(error).split(/\r?\n/u)[0]!.slice(0, KXM_TUI_LIMITS.detailMax);
90
+ }
91
+
92
+ function normalizeSource(source: string): string {
93
+ if (!/^[A-Za-z0-9@][A-Za-z0-9@/._:-]*$/u.test(source) || source.length > KXM_TUI_LIMITS.sourceMax) {
94
+ throw new Error(`invalid kxm tui surface source: ${source.slice(0, 64)}`);
95
+ }
96
+ return source;
97
+ }
98
+
99
+ /**
100
+ * Create a registry.
101
+ *
102
+ * `onInternalError` receives faults that cannot be shown as a field notice (a
103
+ * malformed published surface, a listener that threw). It must never rethrow
104
+ * into the panel: a broken owner dims a section, it does not crash the UI.
105
+ */
106
+ export function createKxmTuiRegistry(options: {
107
+ onInternalError?: (error: unknown, context: string) => void;
108
+ } = {}): KxmTuiRegistry {
109
+ const contributions = new Map<string, KxmTuiContribution>();
110
+ const notices = new Map<string, string>();
111
+ const listeners = new Set<(event: KxmTuiRegistryEvent) => void>();
112
+ /** Per-owner cache so one repaint does not re-read every file on disk. */
113
+ const published = new Map<string, readonly KxmTuiSectionView[]>();
114
+ const issues = new Map<string, readonly KxmTuiIssue[]>();
115
+ let generation = 0;
116
+ let disposed = false;
117
+ let epoch = 0;
118
+
119
+ const report = (error: unknown, context: string): void => {
120
+ if (options.onInternalError) options.onInternalError(error, context);
121
+ };
122
+
123
+ const emit = (event: KxmTuiRegistryEvent): void => {
124
+ for (const listener of Array.from(listeners)) {
125
+ try {
126
+ listener(event);
127
+ } catch (error) {
128
+ report(error, "listener");
129
+ }
130
+ }
131
+ };
132
+
133
+ function readOwner(source: string, force: boolean): readonly KxmTuiSectionView[] {
134
+ if (!force) {
135
+ const cached = published.get(source);
136
+ if (cached) return cached;
137
+ }
138
+ const contribution = contributions.get(source);
139
+ if (!contribution) {
140
+ published.set(source, []);
141
+ return [];
142
+ }
143
+ let sections: readonly KxmTuiSectionView[];
144
+ try {
145
+ const raw = contribution.listSections();
146
+ const ownerIssues = validateKxmTuiSurface(raw);
147
+ if (ownerIssues.length > 0) {
148
+ issues.set(source, ownerIssues);
149
+ // Keep the surface drawable anyway: the panel is where someone repairs a
150
+ // bad file, so a broken section still has to render with its notice.
151
+ sections = raw
152
+ .filter((section): section is KxmTuiSectionView => Boolean(section) && typeof section === "object")
153
+ .map((section) => ({
154
+ ...section,
155
+ notice: section.notice ?? `published surface was refused: ${ownerIssues[0]!.code} at ${ownerIssues[0]!.path}`,
156
+ noticeLevel: "error" as const,
157
+ }));
158
+ } else {
159
+ issues.delete(source);
160
+ sections = raw;
161
+ }
162
+ } catch (error) {
163
+ issues.set(source, [{ path: source, code: "list_sections_failed", message: describe(error) }]);
164
+ report(error, `listSections:${source}`);
165
+ sections = [{ source, id: "error", title: source, order: 900, notice: describe(error), noticeLevel: "error", fields: [] }];
166
+ }
167
+ const pending = notices.get(source);
168
+ const withNotice = pending !== undefined && sections.length > 0
169
+ ? sections.map((section, index) => (index === 0 ? { ...section, notice: pending, noticeLevel: "error" as const } : section))
170
+ : sections;
171
+ published.set(source, withNotice);
172
+ return withNotice;
173
+ }
174
+
175
+ function merged(): readonly KxmTuiSectionView[] {
176
+ const all: KxmTuiSectionView[] = [];
177
+ for (const source of contributions.keys()) all.push(...readOwner(source, false));
178
+ return all.slice().sort((a, b) => a.order - b.order || a.source.localeCompare(b.source) || a.title.localeCompare(b.title));
179
+ }
180
+
181
+ return {
182
+ register(contribution) {
183
+ if (disposed) throw new Error("kxm tui registry is disposed");
184
+ const source = normalizeSource(contribution.source);
185
+ notices.delete(source);
186
+ published.delete(source);
187
+ issues.delete(source);
188
+ contributions.set(source, { ...contribution, source });
189
+ emit({ reason: "publish", source });
190
+ },
191
+ unregister(source) {
192
+ contributions.delete(source);
193
+ published.delete(source);
194
+ issues.delete(source);
195
+ notices.delete(source);
196
+ emit({ reason: "publish", source });
197
+ },
198
+ getSections() {
199
+ return merged();
200
+ },
201
+ getIssues() {
202
+ return [...issues.values()].flat();
203
+ },
204
+ async invoke(invocation) {
205
+ if (disposed) return { ok: false, message: "kxm tui registry is disposed" };
206
+ const contribution = contributions.get(invocation.source);
207
+ if (!contribution) {
208
+ emit({ reason: "refused", source: invocation.source, message: "unknown owner" });
209
+ return { ok: false, message: `${invocation.source} does not own a surface here` };
210
+ }
211
+ const handler = contribution.handlers[invocation.action];
212
+ if (!handler) {
213
+ const message = `${invocation.source} does not accept "${invocation.action}"`;
214
+ emit({ reason: "refused", source: invocation.source, message });
215
+ return { ok: false, message };
216
+ }
217
+ const currentEpoch = epoch;
218
+ try {
219
+ await handler({
220
+ sectionId: invocation.sectionId,
221
+ fieldId: invocation.fieldId,
222
+ action: invocation.action,
223
+ ...(invocation.value === undefined ? {} : { value: invocation.value }),
224
+ });
225
+ } catch (error) {
226
+ const failure = contribution.describeError?.(error, invocation.action) ?? describe(error);
227
+ notices.set(invocation.source, failure);
228
+ emit({ reason: "failed", source: invocation.source, message: failure });
229
+ // Republish so the panel shows the on-disk value plus the reason.
230
+ readOwner(invocation.source, true);
231
+ generation += 1;
232
+ return { ok: false, message: failure };
233
+ }
234
+ if (currentEpoch !== epoch) {
235
+ // A stale reply after a reset must not resurrect an old surface.
236
+ readOwner(invocation.source, true);
237
+ return { ok: false, message: "stale write result was discarded" };
238
+ }
239
+ notices.delete(invocation.source);
240
+ readOwner(invocation.source, true);
241
+ generation += 1;
242
+ emit({ reason: "publish", source: invocation.source });
243
+ return { ok: true };
244
+ },
245
+ subscribe(listener) {
246
+ listeners.add(listener);
247
+ return () => listeners.delete(listener);
248
+ },
249
+ getGeneration() {
250
+ return generation;
251
+ },
252
+ hasSource(source) {
253
+ return contributions.has(source);
254
+ },
255
+ listSources() {
256
+ return [...contributions.keys()];
257
+ },
258
+ dispose() {
259
+ if (disposed) return;
260
+ disposed = true;
261
+ epoch += 1;
262
+ listeners.clear();
263
+ contributions.clear();
264
+ published.clear();
265
+ issues.clear();
266
+ notices.clear();
267
+ },
268
+ };
269
+ }
270
+
271
+ /** Validate a contributed surface up front, for owners that publish constants. */
272
+ export function checkedKxmTuiSections(sections: readonly KxmTuiSectionView[]): KxmTuiSectionView[] {
273
+ return assertKxmTuiSurface(sections);
274
+ }
@@ -0,0 +1,130 @@
1
+ /**
2
+ * Keyboard input helpers for KXM terminal surfaces.
3
+ *
4
+ * The panel reducer must be testable without a terminal, so decoding lives here
5
+ * as pure functions instead of being scattered through `handleInput`. Paste and
6
+ * bracketed input arrive as one buffer, so every entry point is bounded and
7
+ * control bytes are dropped before they can reach a field value.
8
+ */
9
+
10
+ import { decodeKittyPrintable, parseKey } from "@earendil-works/pi-tui";
11
+
12
+ export type KxmTuiInputKind =
13
+ | "up"
14
+ | "down"
15
+ | "left"
16
+ | "right"
17
+ | "home"
18
+ | "end"
19
+ | "pageUp"
20
+ | "pageDown"
21
+ | "enter"
22
+ | "escape"
23
+ | "tab"
24
+ | "backspace"
25
+ | "delete"
26
+ | "ctrlC"
27
+ | "text"
28
+ | "ignored";
29
+
30
+ export interface KxmTuiInput {
31
+ readonly kind: KxmTuiInputKind;
32
+ /** Printable payload for `kind: "text"`. */
33
+ readonly text?: string;
34
+ }
35
+
36
+ /** Printable maximum for one committed value; longer input is truncated. */
37
+ export const KXM_TUI_INPUT_MAX_LENGTH = 4096;
38
+
39
+ const CONTROL_BYTES = /[\u0000-\u0008\u000a-\u001f\u007f\u0080-\u009f]/u;
40
+
41
+ const NAMED_KEYS: Readonly<Record<string, KxmTuiInputKind>> = Object.freeze({
42
+ up: "up",
43
+ down: "down",
44
+ left: "left",
45
+ right: "right",
46
+ home: "home",
47
+ end: "end",
48
+ pageup: "pageUp",
49
+ pagedown: "pageDown",
50
+ enter: "enter",
51
+ return: "enter",
52
+ escape: "escape",
53
+ esc: "escape",
54
+ tab: "tab",
55
+ backspace: "backspace",
56
+ delete: "delete",
57
+ });
58
+
59
+ const CONTROL_BYTES_GLOBAL = /[\u0000-\u0008\u000a-\u001f\u007f\u0080-\u009f]/gu;
60
+
61
+ /** Strip control bytes, collapse newlines, and bound the result. */
62
+ export function normalizeInputText(value: string): string {
63
+ return value.replace(CONTROL_BYTES_GLOBAL, " ").replace(/ {2,}/gu, " ").trim().slice(0, KXM_TUI_INPUT_MAX_LENGTH);
64
+ }
65
+
66
+ /** True when the buffer carries no printable text (pure escape/control input). */
67
+ export function isControlInput(data: string): boolean {
68
+ for (let index = 0; index < data.length; index += 1) {
69
+ const code = data.charCodeAt(index);
70
+ if (code < 0x20 || code === 0x7f) return true;
71
+ }
72
+ return false;
73
+ }
74
+
75
+ /**
76
+ * Decode one input event.
77
+ *
78
+ * Named keys resolve through the Pi key parser, which handles Kitty,
79
+ * modifyOtherKeys, and legacy SS3/CSI forms. A multi-character buffer that is
80
+ * not a key sequence is treated as pasted text.
81
+ */
82
+ export function decodeKxmTuiInput(data: string | undefined): KxmTuiInput {
83
+ if (typeof data !== "string" || data.length === 0) return { kind: "ignored" };
84
+ const named = parseKey(data);
85
+ if (named === "ctrl+c") return { kind: "ctrlC" };
86
+ if (named) {
87
+ const kind = NAMED_KEYS[named.toLowerCase()];
88
+ if (kind) return { kind };
89
+ if (named === "space") return { kind: "text", text: " " };
90
+ // A single printable character comes back as its own key id. Everything
91
+ // else (f1, shift+tab, dead keys) is refused rather than mistaken for text.
92
+ if (Array.from(named).length === 1 && !CONTROL_BYTES.test(named)) {
93
+ return { kind: "text", text: named };
94
+ }
95
+ return { kind: "ignored" };
96
+ }
97
+ const kitty = decodeKittyPrintable(data);
98
+ if (kitty) {
99
+ const text = normalizeInputText(kitty);
100
+ return text ? { kind: "text", text } : { kind: "ignored" };
101
+ }
102
+ // An unrecognized escape sequence is a key we do not model, never text. A
103
+ // paste can legitimately carry newlines and other control bytes, so it is
104
+ // normalised rather than rejected.
105
+ if (data.startsWith("\u001b")) return { kind: "ignored" };
106
+ const pasted = normalizeInputText(data);
107
+ return pasted ? { kind: "text", text: pasted } : { kind: "ignored" };
108
+ }
109
+
110
+ /** Delete the last character; surrogate pairs go together. */
111
+ export function deleteBackward(text: string): string {
112
+ const units = Array.from(text);
113
+ units.pop();
114
+ return units.join("");
115
+ }
116
+
117
+ /** Insert at an index and return the new text plus the advanced caret. */
118
+ export function insertAt(
119
+ text: string,
120
+ caret: number,
121
+ insert: string,
122
+ maxLength = KXM_TUI_INPUT_MAX_LENGTH,
123
+ ): { text: string; caret: number } {
124
+ const units = Array.from(text);
125
+ const at = Math.max(0, Math.min(caret, units.length));
126
+ const added = Array.from(insert);
127
+ const merged = [...units.slice(0, at), ...added, ...units.slice(at)].join("");
128
+ const next = merged.length > maxLength ? merged.slice(0, maxLength) : merged;
129
+ return { text: next, caret: Math.min(next.length, at + added.length) };
130
+ }