@kontextmind/kxm 0.7.39 → 0.7.43

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 +41 -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-runtime-supervisor.mjs +3 -3
  142. package/scripts/package-surfaces.mjs +39 -0
  143. package/scripts/roster-policy.mjs +1 -1
  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,208 @@
1
+ /**
2
+ * Pi TUI adapter for a KXM panel surface.
3
+ *
4
+ * The pure renderer produces lines; this class turns them into real components
5
+ * so the panel gets Pi's viewport, scrollbar, mouse, and differential
6
+ * rendering, and so the same code path works inside a Pi extension
7
+ * (`ctx.ui.custom`) and inside the standalone CLI.
8
+ *
9
+ * It owns only wiring: read the published surface, reduce a key into state and
10
+ * effects, run effects through the registry, then repaint. Configuration rules
11
+ * stay in the owning module, which is the whole point of publishing sections
12
+ * instead of centralising them.
13
+ */
14
+
15
+ import { Box, HStack, ScrollView, Text, TruncatedText, VStack, type Component, type TuiMouseEvent, type TuiMouseEventResult, Key, matchesKey } from "@earendil-works/pi-tui";
16
+ import type { KxmTuiSectionView } from "../types/surface.ts";
17
+ import { fitText } from "./layout.ts";
18
+ import {
19
+ initialKxmTuiPanelState,
20
+ reduceKxmTuiInput,
21
+ reconcileKxmTuiPanelState,
22
+ type KxmTuiEffect,
23
+ type KxmTuiPanelState,
24
+ } from "./panel.ts";
25
+ import { renderKxmTuiPanel } from "./render.ts";
26
+ import type { KxmTuiRegistry } from "../services/registry.ts";
27
+ import type { KxmTuiTheme } from "./theme.ts";
28
+
29
+ export interface KxmTuiPanelComponentOptions {
30
+ readonly registry: KxmTuiRegistry;
31
+ readonly theme: KxmTuiTheme;
32
+ readonly title: string;
33
+ readonly breadcrumb?: string;
34
+ readonly helpLines?: readonly string[];
35
+ readonly requestRender: () => void;
36
+ readonly onQuit: () => void;
37
+ readonly getWidth?: () => number;
38
+ /** Extra footer text, e.g. a hub URL or a dirty-file warning. */
39
+ readonly statusText?: () => string | undefined;
40
+ }
41
+
42
+ export const KXM_TUI_PANEL_HELP: readonly string[] = Object.freeze([
43
+ "↑↓ move · ←→ or Tab panes · PgUp/PgDn section · enter edit or open list",
44
+ "type to filter a list · x clears a value · esc backs out · q quit · h help",
45
+ ]);
46
+
47
+ function rowsComponent(): { stack: VStack; scroll: ScrollView; fill: (rows: string[]) => void } {
48
+ const stack = new VStack([], { gap: 0 });
49
+ const scroll = new ScrollView(stack, { primary: true, overscroll: "contain", scrollbar: "auto" });
50
+ const fill = (rows: string[]): void => {
51
+ stack.clear();
52
+ for (const row of rows) stack.addChild(new TruncatedText(row, 0, 0));
53
+ };
54
+ return { stack, scroll, fill };
55
+ }
56
+
57
+ export class KxmTuiPanelComponent implements Component {
58
+ readonly root = new VStack([], { gap: 0 });
59
+ private readonly options: KxmTuiPanelComponentOptions;
60
+ private readonly body = rowsComponent();
61
+ private sections: readonly KxmTuiSectionView[];
62
+ private state: KxmTuiPanelState;
63
+ private notice: string | undefined;
64
+ private unsubscribe: () => void;
65
+
66
+ constructor(options: KxmTuiPanelComponentOptions) {
67
+ this.options = options;
68
+ this.sections = options.registry.getSections();
69
+ this.state = initialKxmTuiPanelState(this.sections);
70
+ this.unsubscribe = options.registry.subscribe(() => this.refresh());
71
+ this.rebuild();
72
+ }
73
+
74
+ getState(): KxmTuiPanelState {
75
+ return this.state;
76
+ }
77
+
78
+ getSections(): readonly KxmTuiSectionView[] {
79
+ return this.sections;
80
+ }
81
+
82
+ /** Point the cursor at a field, e.g. after opening a named section. */
83
+ focus(sectionId: string, fieldId?: string): void {
84
+ const sectionIndex = this.sections.findIndex((section) => section.id === sectionId);
85
+ if (sectionIndex < 0) return;
86
+ const fields = this.sections[sectionIndex]?.fields ?? [];
87
+ const fieldIndex = fieldId ? Math.max(0, fields.findIndex((field) => field.id === fieldId)) : this.state.fieldIndex;
88
+ this.state = { ...this.state, pane: "fields", sectionIndex, fieldIndex };
89
+ this.rebuild();
90
+ this.options.requestRender();
91
+ }
92
+
93
+ setStatus(message: string | undefined): void {
94
+ this.notice = message;
95
+ this.rebuild();
96
+ this.options.requestRender();
97
+ }
98
+
99
+ refresh(): void {
100
+ const next = this.options.registry.getSections();
101
+ this.sections = next;
102
+ this.state = reconcileKxmTuiPanelState(this.state, next);
103
+ this.rebuild();
104
+ this.options.requestRender();
105
+ }
106
+
107
+ handleInput(data: string): void {
108
+ if (matchesKey(data, Key.pageUp) || matchesKey(data, Key.ctrl("u"))) {
109
+ this.body.scroll.scrollBy(-(this.viewport() - 2));
110
+ this.options.requestRender();
111
+ return;
112
+ }
113
+ if (matchesKey(data, Key.pageDown) || matchesKey(data, Key.ctrl("d"))) {
114
+ this.body.scroll.scrollBy(this.viewport() - 2);
115
+ this.options.requestRender();
116
+ return;
117
+ }
118
+ const step = reduceKxmTuiInput(this.state, this.sections, data);
119
+ this.state = step.state;
120
+ for (const effect of step.effects) this.run(effect);
121
+ this.rebuild();
122
+ this.options.requestRender();
123
+ }
124
+
125
+ private viewport(): number {
126
+ const height = this.body.scroll.viewportHeight;
127
+ return height > 3 ? height : 10;
128
+ }
129
+
130
+ private run(effect: KxmTuiEffect): void {
131
+ if (effect.type === "quit") {
132
+ this.options.onQuit();
133
+ return;
134
+ }
135
+ if (effect.type === "notice") {
136
+ this.notice = effect.message;
137
+ return;
138
+ }
139
+ this.notice = undefined;
140
+ void this.options.registry.invoke(effect.invocation).then((result) => {
141
+ if (!result.ok) this.setStatus(result.message ?? "the owner refused that change");
142
+ else if (this.notice === undefined) this.setStatus(undefined);
143
+ });
144
+ }
145
+
146
+ private footer(): string {
147
+ const status = this.notice ?? this.options.statusText?.();
148
+ const base = "↑↓ · ←→ panes · enter edit · x clear · esc back · h help · q quit";
149
+ return status ? `${base} ${status}` : base;
150
+ }
151
+
152
+ private rebuild(): void {
153
+ const theme = this.options.theme;
154
+ const width = this.options.getWidth?.() ?? 120;
155
+ const title = new Box(1, 0, theme.headerBg);
156
+ title.addChild(new HStack([
157
+ { component: new Text(theme.cursor(this.options.title), 0, 0), basis: 12, shrink: 1, minSize: 6 },
158
+ {
159
+ component: new Text(
160
+ this.options.breadcrumb ? theme.dim(fitText(this.options.breadcrumb, Math.max(4, width - 16))) : "",
161
+ 0,
162
+ 0,
163
+ ),
164
+ grow: 1,
165
+ shrink: 1,
166
+ minSize: 8,
167
+ },
168
+ ], { gap: 2 }));
169
+ this.body.fill(
170
+ renderKxmTuiPanel(this.sections, this.state, theme, {
171
+ title: this.options.title,
172
+ ...(this.options.breadcrumb ? { breadcrumb: this.options.breadcrumb } : {}),
173
+ helpLines: this.options.helpLines ?? KXM_TUI_PANEL_HELP,
174
+ footer: this.footer(),
175
+ }, width),
176
+ );
177
+ // No horizontal padding: the renderer already fits every row to the
178
+ // requested width, and a padded Box would push the last column over and
179
+ // make the row wrapper truncate the value it exists to show.
180
+ const panel = new Box(0, 0, theme.panelBg);
181
+ panel.addChild(this.body.scroll);
182
+ this.root.clear();
183
+ this.root.addChild(title, { basis: "auto", shrink: 0 });
184
+ this.root.addChild(panel, { basis: "auto", grow: 1, shrink: 1, minSize: 1 });
185
+ }
186
+
187
+ handleMouse(event: TuiMouseEvent): TuiMouseEventResult | undefined {
188
+ return this.root.handleMouse?.(event);
189
+ }
190
+
191
+ invalidate(): void {
192
+ this.root.invalidate();
193
+ }
194
+
195
+ render(width: number): string[] {
196
+ return this.root.render(width);
197
+ }
198
+
199
+ dispose(): void {
200
+ this.unsubscribe();
201
+ this.unsubscribe = () => undefined;
202
+ }
203
+ }
204
+
205
+ /** Build a panel component and its initial state for a non-TTY snapshot. */
206
+ export function createKxmTuiPanelComponent(options: KxmTuiPanelComponentOptions): KxmTuiPanelComponent {
207
+ return new KxmTuiPanelComponent(options);
208
+ }
@@ -0,0 +1,309 @@
1
+ /**
2
+ * Pure renderer for KXM panel surfaces.
3
+ *
4
+ * Rendering is a function of (sections, state, width) returning lines, exactly
5
+ * like `renderMeshTui` for `kxm dash`. That is what makes a config surface
6
+ * testable in CI without a PTY and lets the same rows appear inside the Pi TUI,
7
+ * inside `kxm dash`, and in a plain-text audit dump.
8
+ *
9
+ * Every emitted line is fitted to `width`: one over-wide row corrupts the whole
10
+ * frame, so fitting happens here rather than in each caller. The edit caret is
11
+ * drawn as a text marker instead of a reverse-video block so a colourless theme
12
+ * stays free of escape codes.
13
+ */
14
+
15
+ import {
16
+ groupedKxmTuiChoices,
17
+ type KxmTuiChoice,
18
+ type KxmTuiField,
19
+ type KxmTuiSectionView,
20
+ type KxmTuiStatus,
21
+ type KxmTuiStep,
22
+ } from "../types/surface.ts";
23
+ import { stripTerminalSequences } from "@earendil-works/pi-tui";
24
+ import { KXM_TUI_MARKS, alignRight, fitText, formatAge, padRight, progressBar, wrapPlain } from "./layout.ts";
25
+ import { pendingKey, visibleChoices, type KxmTuiPanelState, type KxmTuiPane } from "./panel.ts";
26
+ import { createPlainKxmTuiTheme, type KxmTuiTheme } from "./theme.ts";
27
+
28
+ const SECTION_PANE_RATIO = 1 / 3;
29
+ const MIN_PANE_WIDTH = 16;
30
+ const PROGRESS_WIDTH = 20;
31
+ const OUTPUT_TAIL = 6;
32
+ /** Below this, both panes would be unreadable, so only the focused one draws. */
33
+ const SPLIT_MIN_WIDTH = 76;
34
+
35
+ export interface KxmTuiRenderOptions {
36
+ readonly title: string;
37
+ readonly breadcrumb?: string;
38
+ readonly footer?: string;
39
+ readonly helpLines?: readonly string[];
40
+ readonly nowMs?: number;
41
+ }
42
+
43
+ function statusMark(status: KxmTuiStatus | undefined, theme: KxmTuiTheme, fallback: string): string {
44
+ switch (status) {
45
+ case "ready":
46
+ return theme.fg("success", KXM_TUI_MARKS.ready);
47
+ case "available":
48
+ return theme.fg("accent", KXM_TUI_MARKS.available);
49
+ case "blocked":
50
+ return theme.fg("error", KXM_TUI_MARKS.blocked);
51
+ default:
52
+ return theme.dim(fallback);
53
+ }
54
+ }
55
+
56
+ function cursorMark(active: boolean, theme: KxmTuiTheme): string {
57
+ return active ? theme.cursor(KXM_TUI_MARKS.cursor) : KXM_TUI_MARKS.blankCursor;
58
+ }
59
+
60
+ function paneSplit(width: number): { sections: number; fields: number } {
61
+ const third = Math.max(MIN_PANE_WIDTH, Math.floor(width * SECTION_PANE_RATIO));
62
+ const sections = Math.min(third, Math.max(MIN_PANE_WIDTH, Math.floor(width / 2)));
63
+ return { sections, fields: Math.max(MIN_PANE_WIDTH, width - sections - 1) };
64
+ }
65
+
66
+ function fieldIsPending(state: KxmTuiPanelState, section: KxmTuiSectionView, field: KxmTuiField): boolean {
67
+ return state.pending.includes(pendingKey({ source: section.source, sectionId: section.id, fieldId: field.id }));
68
+ }
69
+
70
+ function sectionRow(
71
+ section: KxmTuiSectionView,
72
+ focused: boolean,
73
+ pane: KxmTuiPane,
74
+ theme: KxmTuiTheme,
75
+ width: number,
76
+ ): string {
77
+ const cursor = cursorMark(focused && pane === "sections", theme);
78
+ const flag = section.notice ? theme.fg(section.noticeLevel === "error" ? "error" : "warning", "!") : " ";
79
+ const count = section.fields.length;
80
+ const unset = section.fields.filter((field) => field.kind !== "info" && field.value === undefined).length;
81
+ const right = unset > 0 ? `${unset} unset / ${count}` : `${count} field${count === 1 ? "" : "s"}`;
82
+ return fitText(alignRight(`${cursor} ${flag} ${section.title}`, right, width), width);
83
+ }
84
+
85
+ function choiceSuffix(choice: KxmTuiChoice): string {
86
+ const parts: string[] = [];
87
+ if (choice.detail) parts.push(choice.detail);
88
+ if (choice.statusText) parts.push(choice.statusText);
89
+ return parts.join(" · ");
90
+ }
91
+
92
+ function choiceRows(field: KxmTuiField, state: KxmTuiPanelState, theme: KxmTuiTheme, width: number): string[] {
93
+ const shown = visibleChoices(field, state.filter);
94
+ const lines: string[] = [];
95
+ if (state.filtering) {
96
+ lines.push(fitText(` ${theme.dim(`filter: ${state.filter || "(type to filter · esc clears)"}`)}`, width));
97
+ }
98
+ if (shown.length === 0) {
99
+ lines.push(fitText(` ${theme.dim("no match")}`, width));
100
+ return lines;
101
+ }
102
+ let position = -1;
103
+ for (const group of groupedKxmTuiChoices(field)) {
104
+ const filtered = shown.filter((choice) => (choice.group ?? "") === group.group);
105
+ if (filtered.length === 0) continue;
106
+ if (group.group) lines.push(fitText(` ${theme.key(group.group.toUpperCase())}`, width));
107
+ for (const choice of filtered) {
108
+ position += 1;
109
+ lines.push(
110
+ fitText(
111
+ alignRight(
112
+ `${cursorMark(position === state.choiceIndex, theme)} ${statusMark(choice.status, theme, KXM_TUI_MARKS.pending)} ${choice.label}`,
113
+ choiceSuffix(choice),
114
+ width,
115
+ ),
116
+ width,
117
+ ),
118
+ );
119
+ }
120
+ }
121
+ return lines;
122
+ }
123
+
124
+ const STEP_MARKS: Record<KxmTuiStep["state"], string> = {
125
+ pending: "·",
126
+ satisfied: KXM_TUI_MARKS.passed,
127
+ running: KXM_TUI_MARKS.active,
128
+ done: KXM_TUI_MARKS.passed,
129
+ failed: KXM_TUI_MARKS.failed,
130
+ };
131
+
132
+ function stepColor(state: KxmTuiStep["state"], theme: KxmTuiTheme): (text: string) => string {
133
+ switch (state) {
134
+ case "running":
135
+ return (text) => theme.fg("accent", text);
136
+ case "done":
137
+ return (text) => theme.fg("success", text);
138
+ case "failed":
139
+ return (text) => theme.fg("error", text);
140
+ default:
141
+ return theme.dim;
142
+ }
143
+ }
144
+
145
+ function stepRows(steps: readonly KxmTuiStep[], theme: KxmTuiTheme, width: number): string[] {
146
+ return steps.map((step) => {
147
+ const paint = stepColor(step.state, theme);
148
+ return fitText(alignRight(` ${paint(STEP_MARKS[step.state])} ${step.label}`, step.detail ?? "", width), width);
149
+ });
150
+ }
151
+
152
+ function progressRow(field: KxmTuiField, theme: KxmTuiTheme, width: number): string {
153
+ const progress = field.progress;
154
+ if (!progress) return "";
155
+ const ratio = progress.ratio;
156
+ const right = ratio === undefined ? "working" : `${Math.round(ratio * 100)}%`;
157
+ return fitText(alignRight(` ${progress.label}`, `${progressBar(ratio, PROGRESS_WIDTH)} ${right}`, width), width);
158
+ }
159
+
160
+ function focusedFieldDetail(
161
+ field: KxmTuiField,
162
+ state: KxmTuiPanelState,
163
+ theme: KxmTuiTheme,
164
+ width: number,
165
+ ): string[] {
166
+ const lines: string[] = [];
167
+ if (field.keyPath) lines.push(fitText(` ${theme.dim(`key: ${field.keyPath}`)}`, width));
168
+ for (const line of wrapPlain(field.detail ?? "", width - 3)) {
169
+ if (line) lines.push(fitText(` ${theme.dim(line)}`, width));
170
+ }
171
+ for (const line of wrapPlain(field.statusText ?? "", width - 3)) {
172
+ if (line) lines.push(fitText(` ${theme.fg("error", line)}`, width));
173
+ }
174
+ const progress = progressRow(field, theme, width);
175
+ if (progress) lines.push(progress);
176
+ if (field.steps?.length) lines.push(...stepRows(field.steps, theme, width));
177
+ if (field.output?.length) {
178
+ lines.push(...field.output.slice(-OUTPUT_TAIL).map((line) => fitText(` ${theme.dim(line)}`, width)));
179
+ }
180
+ if (field.actions?.length) {
181
+ lines.push(fitText(` ${theme.key(field.actions.map((action) => `${action.key} ${action.label}`).join(" · "))}`, width));
182
+ }
183
+ if (state.mode === "choices") lines.push(...choiceRows(field, state, theme, width));
184
+ if (state.mode === "edit" || field.awaitingInput) {
185
+ const draft = state.mode === "edit" ? `${state.draft.slice(0, state.caret)}|${state.draft.slice(state.caret)}` : "|";
186
+ lines.push(fitText(` ${theme.dim("›")} ${draft}`, width));
187
+ }
188
+ return lines;
189
+ }
190
+
191
+ function fieldRows(
192
+ section: KxmTuiSectionView,
193
+ state: KxmTuiPanelState,
194
+ theme: KxmTuiTheme,
195
+ width: number,
196
+ nowMs: number,
197
+ ): string[] {
198
+ const lines: string[] = [];
199
+ section.fields.forEach((field, index) => {
200
+ const focused = state.pane === "fields" && state.fieldIndex === index;
201
+ if (field.kind === "info") {
202
+ lines.push(fitText(` ${theme.dim(field.label)} ${theme.dim(field.value ?? field.placeholder ?? "")}`, width));
203
+ return;
204
+ }
205
+ const pending = fieldIsPending(state, section, field);
206
+ const mark = statusMark(field.status, theme, pending ? "◌" : KXM_TUI_MARKS.pending);
207
+ const value = pending && field.value === undefined ? theme.dim("saving…") : field.value;
208
+ const shown = value ?? theme.dim(field.placeholder ?? "not set");
209
+ const age = field.updatedAt ? theme.dim(` (${formatAge(field.updatedAt, nowMs)})`) : "";
210
+ lines.push(fitText(`${cursorMark(focused, theme)} ${mark} ${alignRight(field.label, `${shown}${age}`, width - 4)}`, width));
211
+ if (focused) lines.push(...focusedFieldDetail(field, state, theme, width));
212
+ });
213
+ return lines;
214
+ }
215
+
216
+ function noticeRows(section: KxmTuiSectionView | undefined, theme: KxmTuiTheme, width: number): string[] {
217
+ if (!section?.notice) return [];
218
+ const token = section.noticeLevel === "error" ? "error" : "warning";
219
+ return wrapPlain(section.notice, width - 2).map((line) => fitText(theme.fg(token, line), width));
220
+ }
221
+
222
+ function singlePaneLines(
223
+ sections: readonly KxmTuiSectionView[],
224
+ state: KxmTuiPanelState,
225
+ theme: KxmTuiTheme,
226
+ width: number,
227
+ nowMs: number,
228
+ ): string[] {
229
+ const section = sections[state.sectionIndex];
230
+ if (state.pane === "fields" && section) {
231
+ return [
232
+ fitText(theme.headerBg(alignRight(` ${section.title}`, section.source, width)), width),
233
+ ...noticeRows(section, theme, width),
234
+ ...fieldRows(section, state, theme, width, nowMs),
235
+ ];
236
+ }
237
+ return [
238
+ fitText(theme.headerBg(alignRight(" SECTIONS", `${sections.length}`, width)), width),
239
+ ...sections.map((candidate, index) => sectionRow(candidate, index === state.sectionIndex, state.pane, theme, width)),
240
+ ];
241
+ }
242
+
243
+ function twoPaneLines(
244
+ sections: readonly KxmTuiSectionView[],
245
+ state: KxmTuiPanelState,
246
+ theme: KxmTuiTheme,
247
+ width: number,
248
+ nowMs: number,
249
+ ): string[] {
250
+ const split = paneSplit(width);
251
+ const left: string[] = [fitText(theme.key(" SECTIONS"), split.sections)];
252
+ sections.forEach((candidate, index) =>
253
+ left.push(sectionRow(candidate, index === state.sectionIndex, state.pane, theme, split.sections)),
254
+ );
255
+ const right: string[] = [];
256
+ const section = sections[state.sectionIndex];
257
+ if (section) {
258
+ right.push(fitText(alignRight(` ${section.title}`, section.source, split.fields), split.fields));
259
+ if (section.detail) right.push(fitText(` ${theme.dim(section.detail)}`, split.fields));
260
+ right.push(...noticeRows(section, theme, split.fields));
261
+ right.push(...fieldRows(section, state, theme, split.fields, nowMs));
262
+ } else {
263
+ right.push(fitText(theme.dim("no surface is published"), split.fields));
264
+ }
265
+ const rows: string[] = [];
266
+ for (let index = 0; index < Math.max(left.length, right.length); index += 1) {
267
+ rows.push(fitText(`${padRight(left[index] ?? "", split.sections)} ${right[index] ?? ""}`, width));
268
+ }
269
+ return rows;
270
+ }
271
+
272
+ /**
273
+ * Draw one frame.
274
+ *
275
+ * Narrow terminals collapse to the focused pane instead of shrinking both, which
276
+ * keeps a field label legible at 48 columns.
277
+ */
278
+ export function renderKxmTuiPanel(
279
+ sections: readonly KxmTuiSectionView[],
280
+ state: KxmTuiPanelState,
281
+ theme: KxmTuiTheme,
282
+ options: KxmTuiRenderOptions,
283
+ width = 120,
284
+ ): string[] {
285
+ const nowMs = options.nowMs ?? Date.now();
286
+ const title = `${theme.cursor(options.title)}${options.breadcrumb ? ` ${theme.dim(options.breadcrumb)}` : ""}`;
287
+ const header = [fitText(title, width)];
288
+ if (state.help) {
289
+ const help = (options.helpLines ?? ["no help published"]).flatMap((line) => wrapPlain(line, Math.max(1, width - 2)));
290
+ return [...header, ...help.map((line) => fitText(line, width)), fitText(theme.dim("esc close help"), width)];
291
+ }
292
+ const body = width < SPLIT_MIN_WIDTH
293
+ ? singlePaneLines(sections, state, theme, width, nowMs)
294
+ : twoPaneLines(sections, state, theme, width, nowMs);
295
+ const footer = options.footer ? [fitText(theme.dim(options.footer), width)] : [];
296
+ return [...header, ...body, ...footer];
297
+ }
298
+
299
+ /** Render a frame to plain text, with all styling removed. */
300
+ export function renderKxmTuiPanelText(
301
+ sections: readonly KxmTuiSectionView[],
302
+ state: KxmTuiPanelState,
303
+ options: KxmTuiRenderOptions,
304
+ width = 120,
305
+ ): string {
306
+ return `${renderKxmTuiPanel(sections, state, createPlainKxmTuiTheme(), options, width)
307
+ .map((line) => stripTerminalSequences(line))
308
+ .join("\n")}\n`;
309
+ }
@@ -0,0 +1,145 @@
1
+ /**
2
+ * Colour seam for KXM terminal surfaces.
3
+ *
4
+ * Components ask for a semantic colour instead of emitting ANSI, so the same
5
+ * renderer works inside the Pi TUI (host theme), inside the standalone `kxm`
6
+ * CLI (this palette), and inside a test or a piped `--json` path (identity
7
+ * functions). The identity theme is load-bearing, not cosmetic: `kxm dash`
8
+ * asserts that a non-TTY snapshot contains no control sequences at all.
9
+ *
10
+ * The palette is the one `kxm dash` has always used, moved here so a second
11
+ * surface cannot drift to a different shade of "this row failed".
12
+ */
13
+
14
+ export type KxmTuiForeground =
15
+ | "text"
16
+ | "accent"
17
+ | "muted"
18
+ | "dim"
19
+ | "success"
20
+ | "error"
21
+ | "warning"
22
+ | "border";
23
+
24
+ /**
25
+ * A semantic palette. Every method takes text and returns styled text.
26
+ *
27
+ * `fg()` is the primitive; the named helpers exist because callers read better
28
+ * with `theme.error(msg)` than with a token string.
29
+ */
30
+ export interface KxmTuiTheme {
31
+ /** False when the theme is a passthrough (non-TTY, `NO_COLOR`, tests). */
32
+ readonly colored: boolean;
33
+ fg(token: KxmTuiForeground, text: string): string;
34
+ accent(text: string): string;
35
+ success(text: string): string;
36
+ error(text: string): string;
37
+ warning(text: string): string;
38
+ muted(text: string): string;
39
+ dim(text: string): string;
40
+ label(text: string): string;
41
+ value(text: string): string;
42
+ key(text: string): string;
43
+ cursor(text: string): string;
44
+ headerBg(text: string): string;
45
+ panelBg(text: string): string;
46
+ }
47
+
48
+ const SGR: Readonly<Record<KxmTuiForeground, string>> = Object.freeze({
49
+ text: "37",
50
+ accent: "1;36",
51
+ muted: "2",
52
+ dim: "2",
53
+ success: "1;32",
54
+ error: "1;31",
55
+ warning: "1;33",
56
+ border: "90",
57
+ });
58
+
59
+ /** Identity theme: emits no escape codes at all. */
60
+ export function createPlainKxmTuiTheme(): KxmTuiTheme {
61
+ const passthrough = (text: string): string => text;
62
+ return {
63
+ colored: false,
64
+ fg: (_token, text) => text,
65
+ accent: passthrough,
66
+ success: passthrough,
67
+ error: passthrough,
68
+ warning: passthrough,
69
+ muted: passthrough,
70
+ dim: passthrough,
71
+ label: passthrough,
72
+ value: passthrough,
73
+ key: passthrough,
74
+ cursor: passthrough,
75
+ headerBg: passthrough,
76
+ panelBg: passthrough,
77
+ };
78
+ }
79
+
80
+ /**
81
+ * SGR palette for the standalone `kxm` CLI, which has no Pi theme to borrow.
82
+ *
83
+ * `color: false` returns the identity theme, so a caller that already knows it
84
+ * is not on a TTY gets plain text without needing environment variables.
85
+ */
86
+ export function createKxmTuiAnsiTheme(color: boolean): KxmTuiTheme {
87
+ if (!color) return createPlainKxmTuiTheme();
88
+ const fg = (token: KxmTuiForeground, text: string): string => `\u001b[${SGR[token]}m${text}\u001b[0m`;
89
+ return {
90
+ colored: true,
91
+ fg,
92
+ accent: (text) => fg("accent", text),
93
+ success: (text) => fg("success", text),
94
+ error: (text) => fg("error", text),
95
+ warning: (text) => fg("warning", text),
96
+ muted: (text) => fg("muted", text),
97
+ dim: (text) => fg("dim", text),
98
+ label: (text) => fg("text", text),
99
+ value: (text) => `\u001b[1m${text}\u001b[0m`,
100
+ key: (text) => fg("muted", text),
101
+ cursor: (text) => fg("accent", text),
102
+ headerBg: (text) => `\u001b[1;97;44m${text}\u001b[0m`,
103
+ panelBg: (text) => `\u001b[48;5;236m${text}\u001b[0m`,
104
+ };
105
+ }
106
+
107
+ /** Choose the CLI theme from the environment, honouring `NO_COLOR`. */
108
+ export function createDefaultKxmTuiTheme(
109
+ env: Record<string, string | undefined> = process.env,
110
+ ): KxmTuiTheme {
111
+ return createKxmTuiAnsiTheme(!(env.NO_COLOR || env.KXM_TUI_NO_COLOR));
112
+ }
113
+
114
+ /** The slice of Pi's `Theme` this kit needs. */
115
+ export interface PiThemeLike {
116
+ fg(token: string, text: string): string;
117
+ bg(token: string, text: string): string;
118
+ }
119
+
120
+ /**
121
+ * Adapt a Pi extension theme so kit components reuse the host's colours.
122
+ *
123
+ * `headerBg`/`panelBg` map onto Pi's selection background: Pi has no notion of
124
+ * a panel fill, and inventing one here would fight the user's theme.
125
+ */
126
+ export function createKxmTuiThemeFromPi(theme: PiThemeLike, colored = true): KxmTuiTheme {
127
+ if (!colored) return createPlainKxmTuiTheme();
128
+ const fg = (token: KxmTuiForeground, text: string): string => theme.fg(token === "dim" ? "muted" : token, text);
129
+ return {
130
+ colored: true,
131
+ fg,
132
+ accent: (text) => fg("accent", text),
133
+ success: (text) => fg("success", text),
134
+ error: (text) => fg("error", text),
135
+ warning: (text) => fg("warning", text),
136
+ muted: (text) => fg("muted", text),
137
+ dim: (text) => fg("dim", text),
138
+ label: (text) => fg("text", text),
139
+ value: (text) => fg("accent", text),
140
+ key: (text) => fg("muted", text),
141
+ cursor: (text) => fg("accent", text),
142
+ headerBg: (text) => theme.bg("selectedBg", text),
143
+ panelBg: (text) => theme.bg("selectedBg", text),
144
+ };
145
+ }