@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.
- package/.claude-plugin/marketplace.json +1 -1
- package/CHANGELOG.md +41 -7
- package/docs/README.md +5 -3
- package/docs/agent-skills.md +1 -1
- package/docs/architecture.md +1 -1
- package/docs/configuration.md +28 -5
- package/docs/{vnext → contracts}/README.md +7 -7
- package/docs/{vnext → contracts}/architecture.md +2 -2
- package/docs/{vnext → contracts}/migration.md +25 -25
- package/docs/{vnext → contracts}/routing.md +2 -2
- package/docs/{vnext → contracts}/synchronization.md +1 -1
- package/docs/{vnext → contracts}/validation.md +7 -7
- package/docs/getting-started.md +1 -1
- package/docs/kb/qa-authentik-authentication.md +47 -0
- package/docs/kb/qa-extension-install-and-hub-bootstrap.md +68 -0
- package/docs/kb/qa-hub-on-a-public-host.md +31 -0
- package/docs/kb/qa-sqlite-vs-duckdb.md +18 -0
- package/docs/kb/qa-what-the-hub-stores.md +47 -0
- package/docs/packages.md +91 -0
- package/docs/templates/architecture.md +1 -1
- package/docs/test-matrix.md +6 -6
- package/docs/tui-components.md +131 -0
- package/examples/README.md +2 -2
- package/examples/{vnext → project}/.kxm/agents/critic-3.yaml +1 -1
- package/examples/{vnext → project}/README.md +4 -4
- package/package.json +18 -8
- package/packages/core/tui/CHANGELOG.md +19 -0
- package/packages/core/tui/LICENSE +21 -0
- package/packages/core/tui/README.md +45 -0
- package/packages/core/tui/dist/index.js +1439 -0
- package/packages/core/tui/src/adapters/pi.ts +73 -0
- package/packages/core/tui/src/adapters/terminal.ts +106 -0
- package/packages/core/tui/src/exports/index.ts +30 -0
- package/packages/core/tui/src/services/registry.ts +274 -0
- package/packages/core/tui/src/tui/keys.ts +130 -0
- package/packages/core/tui/src/tui/layout.ts +117 -0
- package/packages/core/tui/src/tui/panel.ts +435 -0
- package/packages/core/tui/src/tui/panelComponent.ts +208 -0
- package/packages/core/tui/src/tui/render.ts +309 -0
- package/packages/core/tui/src/tui/theme.ts +145 -0
- package/packages/core/tui/src/types/surface.ts +407 -0
- package/plugins/kxm/.claude-plugin/plugin.json +1 -1
- package/plugins/kxm/dist/cli.js +681 -553
- package/plugins/kxm/dist/extension.js +18 -18
- package/plugins/kxm/dist/mcp-server.js +1 -1
- package/plugins/kxm/dist/{vnext-runtime-supervisor.js → runtime-supervisor.js} +553 -553
- package/plugins/kxm/dist/runtime.js +608 -608
- package/plugins/kxm/dist/server.js +13 -13
- package/plugins/kxm/package.json +2 -2
- package/plugins/kxm/skills/kxm-project-setup/SKILL.md +2 -2
- package/plugins/kxm/skills/kxm-runs/SKILL.md +3 -3
- package/plugins/kxm/skills/kxm-session/SKILL.md +1 -1
- package/plugins/kxm/skills/kxm-workflow/SKILL.md +1 -1
- package/plugins/kxm/src/autocomplete.ts +4 -4
- package/plugins/kxm/src/{vnext-bindings.ts → bindings.ts} +48 -48
- package/plugins/kxm/src/cli/hub.ts +3 -3
- package/plugins/kxm/src/cli/{vnext.ts → project.ts} +106 -106
- package/plugins/kxm/src/cli/roles.ts +9 -9
- package/plugins/kxm/src/cli/system.ts +2 -2
- package/plugins/kxm/src/cli/tasks.ts +8 -8
- package/plugins/kxm/src/cli/workflows.ts +8 -8
- package/plugins/kxm/src/cli.ts +47 -47
- package/plugins/kxm/src/config.ts +69 -14
- package/plugins/kxm/src/database.ts +4 -4
- package/plugins/kxm/src/{vnext-engine-artifacts.ts → engine-artifacts.ts} +7 -7
- package/plugins/kxm/src/{vnext-engine-command.ts → engine-command.ts} +40 -40
- package/plugins/kxm/src/{vnext-engine-compile.ts → engine-compile.ts} +64 -64
- package/plugins/kxm/src/{vnext-engine-evidence.ts → engine-evidence.ts} +26 -26
- package/plugins/kxm/src/{vnext-engine-fold.ts → engine-fold.ts} +99 -99
- package/plugins/kxm/src/{vnext-engine-gate-records.ts → engine-gate-records.ts} +105 -105
- package/plugins/kxm/src/{vnext-engine-plan.ts → engine-plan.ts} +69 -69
- package/plugins/kxm/src/{vnext-engine.ts → engine.ts} +502 -502
- package/plugins/kxm/src/gate-hash.ts +10 -0
- package/plugins/kxm/src/{vnext-harness.ts → harness.ts} +1 -1
- package/plugins/kxm/src/hub.ts +1 -1
- package/plugins/kxm/src/init-guide-setup.ts +3 -3
- package/plugins/kxm/src/{vnext-init.ts → init.ts} +93 -93
- package/plugins/kxm/src/kxm-install-kind.ts +1 -1
- package/plugins/kxm/src/kxm-update-config.ts +2 -2
- package/plugins/kxm/src/local-snapshot.ts +23 -23
- package/plugins/kxm/src/mcp-server.ts +1 -1
- package/plugins/kxm/src/{vnext-migrate.ts → migrate.ts} +123 -123
- package/plugins/kxm/src/model-inventory.ts +1 -1
- package/plugins/kxm/src/{vnext-oneshot-evidence.ts → oneshot-evidence.ts} +2 -2
- package/plugins/kxm/src/{vnext-oneshot-process.ts → oneshot-process.ts} +4 -4
- package/plugins/kxm/src/{vnext-oneshot-producer.ts → oneshot-producer.ts} +24 -24
- package/plugins/kxm/src/{vnext-permission.ts → permission.ts} +86 -86
- package/plugins/kxm/src/{vnext-pi-producer.ts → pi-producer.ts} +13 -13
- package/plugins/kxm/src/{vnext-config.ts → project-config.ts} +177 -177
- package/plugins/kxm/src/{vnext-repair.ts → repair.ts} +159 -159
- package/plugins/kxm/src/restricted-yaml.d.mts +3 -3
- package/plugins/kxm/src/restricted-yaml.mjs +4 -4
- package/plugins/kxm/src/{vnext-runtime-owner.ts → runtime-owner.ts} +35 -35
- package/plugins/kxm/src/{vnext-runtime.ts → runtime-service.ts} +136 -136
- package/plugins/kxm/src/{vnext-runtime-store.ts → runtime-store.ts} +145 -145
- package/plugins/kxm/src/{vnext-runtime-supervisor.ts → runtime-supervisor.ts} +80 -80
- package/plugins/kxm/src/runtime.ts +6 -6
- package/plugins/kxm/src/session-work.ts +3 -3
- package/plugins/kxm/src/studio-layout.ts +6 -6
- package/plugins/kxm/src/{vnext-template.ts → template.ts} +33 -33
- package/plugins/kxm/src/tui.ts +10 -24
- package/schemas/{vnext/README.md → README.md} +3 -3
- package/schemas/{vnext/agent.schema.json → agent.schema.json} +1 -1
- package/schemas/{vnext/assignment-result.schema.json → assignment-result.schema.json} +1 -1
- package/schemas/{vnext/backup-manifest.schema.json → backup-manifest.schema.json} +1 -1
- package/schemas/{vnext/candidate.schema.json → candidate.schema.json} +1 -1
- package/schemas/{vnext/common.schema.json → common.schema.json} +2 -2
- package/schemas/{vnext/context-candidate.schema.json → context-candidate.schema.json} +1 -1
- package/schemas/{vnext/context-packet.schema.json → context-packet.schema.json} +1 -1
- package/schemas/{vnext/delivery-manifest.schema.json → delivery-manifest.schema.json} +1 -1
- package/schemas/{vnext/drive-receipt.schema.json → drive-receipt.schema.json} +2 -2
- package/schemas/{vnext/environment.schema.json → environment.schema.json} +1 -1
- package/schemas/{vnext/gate-registry.schema.json → gate-registry.schema.json} +1 -1
- package/schemas/{vnext/handoff-manifest.schema.json → handoff-manifest.schema.json} +1 -1
- package/schemas/{vnext/init-operation.schema.json → init-operation.schema.json} +1 -1
- package/schemas/{vnext/local-repository-bindings.schema.json → local-repository-bindings.schema.json} +1 -1
- package/schemas/{vnext/memory-record.schema.json → memory-record.schema.json} +1 -1
- package/schemas/{vnext/migration-decision.schema.json → migration-decision.schema.json} +1 -1
- package/schemas/{vnext/migration-plan.schema.json → migration-plan.schema.json} +1 -1
- package/schemas/{vnext/migration-receipt.schema.json → migration-receipt.schema.json} +1 -1
- package/schemas/{vnext/model.schema.json → model.schema.json} +1 -1
- package/schemas/{vnext/modes.schema.json → modes.schema.json} +1 -1
- package/schemas/{vnext/permission-diff.schema.json → permission-diff.schema.json} +1 -1
- package/schemas/policy-draft/README.md +2 -2
- package/schemas/{vnext/prices.schema.json → prices.schema.json} +1 -1
- package/schemas/{vnext/project.schema.json → project.schema.json} +1 -1
- package/schemas/{vnext/repository.schema.json → repository.schema.json} +1 -1
- package/schemas/{vnext/role.schema.json → role.schema.json} +1 -1
- package/schemas/{vnext/run-event.schema.json → run-event.schema.json} +1 -1
- package/schemas/{vnext/session-brief.schema.json → session-brief.schema.json} +2 -2
- package/schemas/{vnext/sync-event.schema.json → sync-event.schema.json} +1 -1
- package/schemas/{vnext/template-provenance.schema.json → template-provenance.schema.json} +1 -1
- package/schemas/{vnext/workflow.schema.json → workflow.schema.json} +1 -1
- package/scripts/build-package.mjs +59 -0
- package/scripts/build-runtime.mjs +3 -2
- package/scripts/check-generated.mjs +2 -1
- package/scripts/check-versions.mjs +12 -1
- package/scripts/docker-install-smoke.mjs +268 -0
- package/scripts/emit-codex-artifacts.mjs +1 -1
- package/scripts/kxm-bump-version.mjs +53 -3
- package/scripts/kxm-runtime-supervisor.mjs +3 -3
- package/scripts/package-surfaces.mjs +39 -0
- package/scripts/roster-policy.mjs +1 -1
- package/plugins/kxm/src/vnext-gate-hash.ts +0 -10
- /package/docs/{vnext → contracts}/effects-and-recovery.md +0 -0
- /package/docs/{vnext → contracts}/lifecycles.md +0 -0
- /package/docs/{vnext → contracts}/terminology.md +0 -0
- /package/examples/{vnext → project}/.kxm/agents/coordinator.yaml +0 -0
- /package/examples/{vnext → project}/.kxm/agents/critic-1.yaml +0 -0
- /package/examples/{vnext → project}/.kxm/agents/critic-2.yaml +0 -0
- /package/examples/{vnext → project}/.kxm/agents/implementer.yaml +0 -0
- /package/examples/{vnext → project}/.kxm/agents/planner.yaml +0 -0
- /package/examples/{vnext → project}/.kxm/agents/reproducer.yaml +0 -0
- /package/examples/{vnext → project}/.kxm/agents/reviewer.yaml +0 -0
- /package/examples/{vnext → project}/.kxm/gates.yaml +0 -0
- /package/examples/{vnext → project}/.kxm/models/critic-claude.yaml +0 -0
- /package/examples/{vnext → project}/.kxm/models/critic-gemini.yaml +0 -0
- /package/examples/{vnext → project}/.kxm/models/critic-grok.yaml +0 -0
- /package/examples/{vnext → project}/.kxm/models/implementation.yaml +0 -0
- /package/examples/{vnext → project}/.kxm/models/primary.yaml +0 -0
- /package/examples/{vnext → project}/.kxm/prices.yaml +0 -0
- /package/examples/{vnext → project}/.kxm/project/env.yaml +0 -0
- /package/examples/{vnext → project}/.kxm/project.yaml +0 -0
- /package/examples/{vnext → project}/.kxm/repo/repo.yaml +0 -0
- /package/examples/{vnext → project}/.kxm/workflows/default.yaml +0 -0
- /package/examples/{vnext → project}/.kxm/workflows/fix.yaml +0 -0
- /package/examples/{vnext → project}/.kxm/workflows/improve.yaml +0 -0
- /package/examples/{vnext → project}/records/assignment-result-recorded.json +0 -0
- /package/examples/{vnext → project}/records/assignment-result.json +0 -0
- /package/examples/{vnext → project}/records/context-candidate.json +0 -0
- /package/examples/{vnext → project}/records/delivery-manifest.json +0 -0
- /package/examples/{vnext → project}/records/effect-uncertainty-resolved-sync.json +0 -0
- /package/examples/{vnext → project}/records/effect-uncertainty-resolved.json +0 -0
- /package/examples/{vnext → project}/records/run-created.json +0 -0
- /package/examples/{vnext → project}/records/sync-event.json +0 -0
- /package/examples/{vnext → project}/repositories/api/.kxm/repo/env.yaml +0 -0
- /package/examples/{vnext → project}/repositories/api/.kxm/repo/repo.yaml +0 -0
- /package/examples/{vnext → project}/repositories/web/.kxm/repo/repo.yaml +0 -0
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure text layout helpers for KXM terminal surfaces.
|
|
3
|
+
*
|
|
4
|
+
* Terminal rules are easy to get subtly wrong: an over-wide line corrupts the
|
|
5
|
+
* frame, and padding must be measured on visible width because ANSI runs and
|
|
6
|
+
* wide characters both lie about length. Width maths comes from Pi here so
|
|
7
|
+
* `kxm dash` and the config surfaces agree on one implementation.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
11
|
+
|
|
12
|
+
export const KXM_TUI_ELLIPSIS = "…";
|
|
13
|
+
|
|
14
|
+
/** Truncate to an exact visible width, adding an ellipsis when cut. */
|
|
15
|
+
export function fitText(text: string, width: number): string {
|
|
16
|
+
if (width <= 0) return "";
|
|
17
|
+
if (visibleWidth(text) <= width) return text;
|
|
18
|
+
if (!text.includes("\u001b")) {
|
|
19
|
+
// Plain text gets a grapheme-safe slice. Going through the ANSI-aware
|
|
20
|
+
// truncator here would append reset sequences to output that has no
|
|
21
|
+
// styling, and a colourless frame must contain no escape codes at all.
|
|
22
|
+
let out = "";
|
|
23
|
+
for (const unit of Array.from(text)) {
|
|
24
|
+
if (visibleWidth(out) + visibleWidth(unit) > width - KXM_TUI_ELLIPSIS.length) break;
|
|
25
|
+
out += unit;
|
|
26
|
+
}
|
|
27
|
+
return out + KXM_TUI_ELLIPSIS;
|
|
28
|
+
}
|
|
29
|
+
return truncateToWidth(text, width, KXM_TUI_ELLIPSIS);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Left text plus right-aligned detail with filler between, within `width`. */
|
|
33
|
+
export function alignRight(left: string, right: string, width: number): string {
|
|
34
|
+
if (width <= 0) return "";
|
|
35
|
+
const detail = fitText(right, Math.max(0, width - 2));
|
|
36
|
+
const detailWidth = visibleWidth(detail);
|
|
37
|
+
const leftWidth = Math.max(1, width - detailWidth - 1);
|
|
38
|
+
const fitted = fitText(left, leftWidth);
|
|
39
|
+
const filler = Math.max(1, width - visibleWidth(fitted) - detailWidth);
|
|
40
|
+
return `${fitted}${" ".repeat(filler)}${detail}`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Pad on the right to a fixed visible width; never truncates. */
|
|
44
|
+
export function padRight(text: string, width: number): string {
|
|
45
|
+
const missing = width - visibleWidth(text);
|
|
46
|
+
return missing > 0 ? text + " ".repeat(missing) : text;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Plain-text word wrap for published strings that carry no styling.
|
|
51
|
+
*
|
|
52
|
+
* `wrapText` above is ANSI-preserving and is what styled rows use; this variant
|
|
53
|
+
* exists because a panel also draws owner-supplied prose (notices, errors,
|
|
54
|
+
* details) that must never be re-wrapped after styling.
|
|
55
|
+
*/
|
|
56
|
+
export function wrapPlain(text: string, width: number): string[] {
|
|
57
|
+
if (width <= 0) return [];
|
|
58
|
+
const lines: string[] = [];
|
|
59
|
+
let current = "";
|
|
60
|
+
for (const word of text.split(/\s+/u).filter(Boolean)) {
|
|
61
|
+
if (!current.length) current = word;
|
|
62
|
+
else if (visibleWidth(current) + 1 + visibleWidth(word) <= width) current = `${current} ${word}`;
|
|
63
|
+
else {
|
|
64
|
+
lines.push(current);
|
|
65
|
+
current = word;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
if (current.length) lines.push(current);
|
|
69
|
+
return lines.length ? lines : [];
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Compact elapsed time: `12s`, `4m07s`, `2h15m`, `3d04h`.
|
|
74
|
+
*
|
|
75
|
+
* Unparseable or future timestamps return `—` rather than a fake age, so clock
|
|
76
|
+
* skew in a hub record cannot read as "just now".
|
|
77
|
+
*/
|
|
78
|
+
export function formatAge(iso: string | undefined, nowMs: number): string {
|
|
79
|
+
if (!iso) return "—";
|
|
80
|
+
const at = Date.parse(iso);
|
|
81
|
+
if (!Number.isFinite(at)) return "—";
|
|
82
|
+
const seconds = Math.floor((nowMs - at) / 1000);
|
|
83
|
+
if (seconds < 0) return "—";
|
|
84
|
+
if (seconds < 60) return `${seconds}s`;
|
|
85
|
+
const minutes = Math.floor(seconds / 60);
|
|
86
|
+
if (minutes < 60) return `${minutes}m${String(seconds % 60).padStart(2, "0")}s`;
|
|
87
|
+
const hours = Math.floor(minutes / 60);
|
|
88
|
+
if (hours < 24) return `${hours}h${String(minutes % 60).padStart(2, "0")}m`;
|
|
89
|
+
const days = Math.floor(hours / 24);
|
|
90
|
+
return `${days}d${String(hours % 24).padStart(2, "0")}h`;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Fixed-width bar. A missing ratio draws an indeterminate bar, which is what an
|
|
95
|
+
* owner that knows it is busy but not how far along should get.
|
|
96
|
+
*/
|
|
97
|
+
export function progressBar(ratio: number | undefined, width = 10): string {
|
|
98
|
+
const cells = Math.max(1, width);
|
|
99
|
+
if (ratio === undefined || !Number.isFinite(ratio)) return "░".repeat(cells);
|
|
100
|
+
const filled = Math.round(Math.max(0, Math.min(1, ratio)) * cells);
|
|
101
|
+
return "█".repeat(filled) + "░".repeat(Math.max(0, cells - filled));
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Glyph set shared by steppers, choice lists, and gate rows. */
|
|
105
|
+
export const KXM_TUI_MARKS = Object.freeze({
|
|
106
|
+
pending: "○",
|
|
107
|
+
active: "▶",
|
|
108
|
+
waiting: "⧗",
|
|
109
|
+
passed: "✔",
|
|
110
|
+
failed: "✖",
|
|
111
|
+
cancelled: "⊘",
|
|
112
|
+
blocked: "✗",
|
|
113
|
+
ready: "●",
|
|
114
|
+
available: "◐",
|
|
115
|
+
cursor: "▸",
|
|
116
|
+
blankCursor: " ",
|
|
117
|
+
});
|
|
@@ -0,0 +1,435 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure state machine for a KXM panel surface.
|
|
3
|
+
*
|
|
4
|
+
* Navigation, editing, and choice filtering live here as a reducer so the whole
|
|
5
|
+
* interaction is testable without a terminal — the same contract that keeps
|
|
6
|
+
* `applyMeshTuiKey` in `kxm dash` testable. The component layer owns rendering
|
|
7
|
+
* and the registry round-trip; nothing here touches the filesystem or a hub.
|
|
8
|
+
*
|
|
9
|
+
* There is deliberately no local echo of an edit. A typed or selected value is
|
|
10
|
+
* emitted as an effect for the owning module to apply, and the field stays
|
|
11
|
+
* pending until that owner republishes. A rejected value therefore comes back
|
|
12
|
+
* with its reason instead of the panel showing a value that was never written.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import {
|
|
16
|
+
KXM_TUI_ABORT_ACTION,
|
|
17
|
+
KXM_TUI_CLEAR_ACTION,
|
|
18
|
+
KXM_TUI_SET_ACTION,
|
|
19
|
+
fieldIsEditable,
|
|
20
|
+
isSelectableField,
|
|
21
|
+
type KxmTuiChoice,
|
|
22
|
+
type KxmTuiField,
|
|
23
|
+
type KxmTuiInvocation,
|
|
24
|
+
type KxmTuiSectionView,
|
|
25
|
+
} from "../types/surface.ts";
|
|
26
|
+
import { decodeKxmTuiInput, deleteBackward, insertAt, normalizeInputText, type KxmTuiInput } from "./keys.ts";
|
|
27
|
+
|
|
28
|
+
export type KxmTuiPane = "sections" | "fields";
|
|
29
|
+
export type KxmTuiMode = "browse" | "edit" | "choices";
|
|
30
|
+
|
|
31
|
+
export interface KxmTuiPanelState {
|
|
32
|
+
readonly pane: KxmTuiPane;
|
|
33
|
+
readonly mode: KxmTuiMode;
|
|
34
|
+
readonly sectionIndex: number;
|
|
35
|
+
readonly fieldIndex: number;
|
|
36
|
+
readonly choiceIndex: number;
|
|
37
|
+
readonly draft: string;
|
|
38
|
+
readonly caret: number;
|
|
39
|
+
readonly filter: string;
|
|
40
|
+
readonly filtering: boolean;
|
|
41
|
+
readonly help: boolean;
|
|
42
|
+
/** Keys of fields awaiting an owner reply: `source\u0000section\u0000field`. */
|
|
43
|
+
readonly pending: readonly string[];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export type KxmTuiEffect =
|
|
47
|
+
| { readonly type: "invoke"; readonly invocation: KxmTuiInvocation }
|
|
48
|
+
| { readonly type: "quit" }
|
|
49
|
+
| { readonly type: "notice"; readonly message: string };
|
|
50
|
+
|
|
51
|
+
export interface KxmTuiPanelStep {
|
|
52
|
+
readonly state: KxmTuiPanelState;
|
|
53
|
+
readonly effects: readonly KxmTuiEffect[];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const PANE_KEYS = new Set(["j", "k", "h", "l"]);
|
|
57
|
+
|
|
58
|
+
export function initialKxmTuiPanelState(sections: readonly KxmTuiSectionView[]): KxmTuiPanelState {
|
|
59
|
+
return {
|
|
60
|
+
pane: "sections",
|
|
61
|
+
mode: "browse",
|
|
62
|
+
sectionIndex: 0,
|
|
63
|
+
fieldIndex: firstSelectableFieldIndex(sections[0]?.fields ?? []),
|
|
64
|
+
choiceIndex: 0,
|
|
65
|
+
draft: "",
|
|
66
|
+
caret: 0,
|
|
67
|
+
filter: "",
|
|
68
|
+
filtering: false,
|
|
69
|
+
help: false,
|
|
70
|
+
pending: [],
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function pendingKey(invocation: { source: string; sectionId: string; fieldId: string }): string {
|
|
75
|
+
return `${invocation.source}\u0000${invocation.sectionId}\u0000${invocation.fieldId}`;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function firstSelectableFieldIndex(fields: readonly KxmTuiField[]): number {
|
|
79
|
+
const index = fields.findIndex(isSelectableField);
|
|
80
|
+
return index < 0 ? 0 : index;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Choices left after the panel's filter, in display order. */
|
|
84
|
+
export function visibleChoices(field: KxmTuiField | undefined, filter: string): KxmTuiChoice[] {
|
|
85
|
+
const choices = field?.choices ?? [];
|
|
86
|
+
const needle = filter.trim().toLowerCase();
|
|
87
|
+
if (!needle) return [...choices];
|
|
88
|
+
return choices.filter((choice) =>
|
|
89
|
+
`${choice.group ?? ""} ${choice.label} ${choice.id} ${choice.detail ?? ""}`.toLowerCase().includes(needle),
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function currentSection(
|
|
94
|
+
sections: readonly KxmTuiSectionView[],
|
|
95
|
+
state: KxmTuiPanelState,
|
|
96
|
+
): KxmTuiSectionView | undefined {
|
|
97
|
+
return sections[state.sectionIndex];
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function currentField(
|
|
101
|
+
sections: readonly KxmTuiSectionView[],
|
|
102
|
+
state: KxmTuiPanelState,
|
|
103
|
+
): KxmTuiField | undefined {
|
|
104
|
+
return currentSection(sections, state)?.fields[state.fieldIndex];
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Re-clamp a state after an owner republished.
|
|
109
|
+
*
|
|
110
|
+
* Any republish is an answer, so nothing stays pending past it. A field that
|
|
111
|
+
* grew an error, or is waiting for a typed line, keeps edit mode and the draft
|
|
112
|
+
* so the typed value is not lost; otherwise edit mode ends.
|
|
113
|
+
*/
|
|
114
|
+
export function reconcileKxmTuiPanelState(
|
|
115
|
+
state: KxmTuiPanelState,
|
|
116
|
+
sections: readonly KxmTuiSectionView[],
|
|
117
|
+
): KxmTuiPanelState {
|
|
118
|
+
const sectionIndex = Math.min(state.sectionIndex, Math.max(0, sections.length - 1));
|
|
119
|
+
const fields = sections[sectionIndex]?.fields ?? [];
|
|
120
|
+
let fieldIndex = Math.min(state.fieldIndex, Math.max(0, fields.length - 1));
|
|
121
|
+
if (!isSelectableField(fields[fieldIndex])) fieldIndex = firstSelectableFieldIndex(fields);
|
|
122
|
+
const field = fields[fieldIndex];
|
|
123
|
+
const keepsEdit = state.mode === "edit" && (field?.statusText !== undefined || field?.awaitingInput === true);
|
|
124
|
+
return {
|
|
125
|
+
...state,
|
|
126
|
+
sectionIndex,
|
|
127
|
+
fieldIndex,
|
|
128
|
+
mode: keepsEdit ? "edit" : "browse",
|
|
129
|
+
choiceIndex: 0,
|
|
130
|
+
pending: [],
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function focusedFieldIsEditable(
|
|
135
|
+
sections: readonly KxmTuiSectionView[],
|
|
136
|
+
state: KxmTuiPanelState,
|
|
137
|
+
): boolean {
|
|
138
|
+
return fieldIsEditable(currentField(sections, state));
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Advance the panel by one input event.
|
|
143
|
+
*
|
|
144
|
+
* `sections` is the current published surface. The reducer never mutates it and
|
|
145
|
+
* never calls an owner directly; it returns effects for the caller to run.
|
|
146
|
+
*/
|
|
147
|
+
export function reduceKxmTuiInput(
|
|
148
|
+
state: KxmTuiPanelState,
|
|
149
|
+
sections: readonly KxmTuiSectionView[],
|
|
150
|
+
input: string,
|
|
151
|
+
): KxmTuiPanelStep {
|
|
152
|
+
return reduceDecoded(state, sections, decodeKxmTuiInput(input));
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function invokeEffect(
|
|
156
|
+
state: KxmTuiPanelState,
|
|
157
|
+
section: KxmTuiSectionView,
|
|
158
|
+
field: KxmTuiField,
|
|
159
|
+
action: string,
|
|
160
|
+
value?: string,
|
|
161
|
+
): KxmTuiPanelStep {
|
|
162
|
+
const invocation: KxmTuiInvocation = {
|
|
163
|
+
source: section.source,
|
|
164
|
+
sectionId: section.id,
|
|
165
|
+
fieldId: field.id,
|
|
166
|
+
action,
|
|
167
|
+
...(value === undefined ? {} : { value }),
|
|
168
|
+
};
|
|
169
|
+
const key = pendingKey(invocation);
|
|
170
|
+
return {
|
|
171
|
+
state: { ...state, pending: state.pending.includes(key) ? state.pending : [...state.pending, key] },
|
|
172
|
+
effects: [{ type: "invoke", invocation }],
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function moveSection(
|
|
177
|
+
state: KxmTuiPanelState,
|
|
178
|
+
sections: readonly KxmTuiSectionView[],
|
|
179
|
+
delta: number,
|
|
180
|
+
): KxmTuiPanelState {
|
|
181
|
+
if (sections.length === 0) return state;
|
|
182
|
+
const next = Math.max(0, Math.min(state.sectionIndex + delta, sections.length - 1));
|
|
183
|
+
if (next === state.sectionIndex) return state;
|
|
184
|
+
return {
|
|
185
|
+
...state,
|
|
186
|
+
sectionIndex: next,
|
|
187
|
+
fieldIndex: firstSelectableFieldIndex(sections[next]?.fields ?? []),
|
|
188
|
+
mode: "browse",
|
|
189
|
+
choiceIndex: 0,
|
|
190
|
+
draft: "",
|
|
191
|
+
caret: 0,
|
|
192
|
+
filter: "",
|
|
193
|
+
filtering: false,
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** Info fields are labels, so the cursor steps over them to the next control. */
|
|
198
|
+
function moveField(
|
|
199
|
+
state: KxmTuiPanelState,
|
|
200
|
+
fields: readonly KxmTuiField[],
|
|
201
|
+
delta: number,
|
|
202
|
+
): KxmTuiPanelState {
|
|
203
|
+
const step = delta < 0 ? -1 : 1;
|
|
204
|
+
let next = state.fieldIndex + step;
|
|
205
|
+
while (next >= 0 && next < fields.length && !isSelectableField(fields[next])) next += step;
|
|
206
|
+
if (next < 0 || next >= fields.length) return state;
|
|
207
|
+
return {
|
|
208
|
+
...state,
|
|
209
|
+
fieldIndex: next,
|
|
210
|
+
mode: "browse",
|
|
211
|
+
choiceIndex: 0,
|
|
212
|
+
draft: "",
|
|
213
|
+
caret: 0,
|
|
214
|
+
filter: "",
|
|
215
|
+
filtering: false,
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function moveChoice(state: KxmTuiPanelState, count: number, delta: number): KxmTuiPanelState {
|
|
220
|
+
if (count === 0) return state;
|
|
221
|
+
return { ...state, choiceIndex: Math.max(0, Math.min(state.choiceIndex + delta, count - 1)) };
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function choiceAt(field: KxmTuiField, state: KxmTuiPanelState): KxmTuiChoice | undefined {
|
|
225
|
+
const choices = visibleChoices(field, state.filter);
|
|
226
|
+
return choices[Math.max(0, Math.min(state.choiceIndex, choices.length - 1))];
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function enterField(
|
|
230
|
+
state: KxmTuiPanelState,
|
|
231
|
+
section: KxmTuiSectionView,
|
|
232
|
+
field: KxmTuiField | undefined,
|
|
233
|
+
): KxmTuiPanelStep {
|
|
234
|
+
if (!field) return { state, effects: [] };
|
|
235
|
+
if (field.kind === "choice") {
|
|
236
|
+
if ((field.choices?.length ?? 0) === 0) {
|
|
237
|
+
return { state, effects: [{ type: "notice", message: `${field.label} has nothing to offer yet` }] };
|
|
238
|
+
}
|
|
239
|
+
return { state: { ...state, mode: "choices", choiceIndex: 0 }, effects: [] };
|
|
240
|
+
}
|
|
241
|
+
if (field.kind === "enum") {
|
|
242
|
+
const choices = field.choices ?? [];
|
|
243
|
+
if (choices.length === 0) {
|
|
244
|
+
return { state, effects: [{ type: "notice", message: `${field.label} has no values published` }] };
|
|
245
|
+
}
|
|
246
|
+
const current = choices.findIndex((choice) => choice.id === field.value);
|
|
247
|
+
const next = choices[(current + 1) % choices.length]!;
|
|
248
|
+
if (next.status === "blocked") {
|
|
249
|
+
return { state, effects: [{ type: "notice", message: next.statusText ?? `${next.label} is unavailable` }] };
|
|
250
|
+
}
|
|
251
|
+
return invokeEffect(state, section, field, next.action ?? KXM_TUI_SET_ACTION, next.id);
|
|
252
|
+
}
|
|
253
|
+
if (field.kind !== "text") return { state, effects: [] };
|
|
254
|
+
return { state: { ...state, mode: "edit", draft: field.value ?? "", caret: (field.value ?? "").length }, effects: [] };
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function reduceDecoded(
|
|
258
|
+
state: KxmTuiPanelState,
|
|
259
|
+
sections: readonly KxmTuiSectionView[],
|
|
260
|
+
decoded: KxmTuiInput,
|
|
261
|
+
): KxmTuiPanelStep {
|
|
262
|
+
const section = sections[state.sectionIndex];
|
|
263
|
+
const field = currentField(sections, state);
|
|
264
|
+
|
|
265
|
+
if (decoded.kind === "ctrlC") {
|
|
266
|
+
if (field?.busy && section) {
|
|
267
|
+
return invokeEffect(state, section, field, KXM_TUI_ABORT_ACTION);
|
|
268
|
+
}
|
|
269
|
+
return { state, effects: [{ type: "quit" }] };
|
|
270
|
+
}
|
|
271
|
+
if (!section) return { state, effects: [{ type: "quit" }] };
|
|
272
|
+
if (state.mode === "edit") return reduceEdit(state, section, field, decoded);
|
|
273
|
+
if (state.mode === "choices") return reduceChoices(state, section, field, decoded);
|
|
274
|
+
return reduceBrowse(state, sections, section, field, decoded);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** Byte offset just before the caret, for slicing a surrogate-safe draft. */
|
|
278
|
+
function caretSource(text: string, caret: number): number {
|
|
279
|
+
return Array.from(text).slice(0, caret).join("").length;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function reduceEdit(
|
|
283
|
+
state: KxmTuiPanelState,
|
|
284
|
+
section: KxmTuiSectionView,
|
|
285
|
+
field: KxmTuiField | undefined,
|
|
286
|
+
decoded: KxmTuiInput,
|
|
287
|
+
): KxmTuiPanelStep {
|
|
288
|
+
if (!field) return { state: { ...state, mode: "browse" }, effects: [] };
|
|
289
|
+
switch (decoded.kind) {
|
|
290
|
+
case "escape":
|
|
291
|
+
return { state: { ...state, mode: "browse", draft: "", caret: 0 }, effects: [] };
|
|
292
|
+
case "enter": {
|
|
293
|
+
const value = normalizeInputText(state.draft).trim();
|
|
294
|
+
if (!value) return { state: { ...state, mode: "browse", draft: "", caret: 0 }, effects: [] };
|
|
295
|
+
const step = invokeEffect(state, section, field, KXM_TUI_SET_ACTION, value);
|
|
296
|
+
return { state: { ...step.state, mode: "browse", draft: "", caret: 0 }, effects: step.effects };
|
|
297
|
+
}
|
|
298
|
+
case "backspace": {
|
|
299
|
+
if (state.caret === 0) return { state, effects: [] };
|
|
300
|
+
const cut = caretSource(state.draft, state.caret);
|
|
301
|
+
const before = deleteBackward(state.draft.slice(0, cut));
|
|
302
|
+
const draft = `${before}${state.draft.slice(cut)}`;
|
|
303
|
+
return { state: { ...state, draft, caret: Array.from(before).length }, effects: [] };
|
|
304
|
+
}
|
|
305
|
+
case "delete": {
|
|
306
|
+
const units = Array.from(state.draft);
|
|
307
|
+
const text = [...units.slice(0, state.caret), ...units.slice(state.caret + 1)].join("");
|
|
308
|
+
return { state: { ...state, draft: text }, effects: [] };
|
|
309
|
+
}
|
|
310
|
+
case "left":
|
|
311
|
+
return { state: { ...state, caret: Math.max(0, state.caret - 1) }, effects: [] };
|
|
312
|
+
case "right":
|
|
313
|
+
return { state: { ...state, caret: Math.min(Array.from(state.draft).length, state.caret + 1) }, effects: [] };
|
|
314
|
+
case "home":
|
|
315
|
+
return { state: { ...state, caret: 0 }, effects: [] };
|
|
316
|
+
case "end":
|
|
317
|
+
return { state: { ...state, caret: Array.from(state.draft).length }, effects: [] };
|
|
318
|
+
case "text": {
|
|
319
|
+
const text = decoded.text ?? "";
|
|
320
|
+
if (!text) return { state, effects: [] };
|
|
321
|
+
const inserted = insertAt(state.draft, state.caret, text);
|
|
322
|
+
return { state: { ...state, draft: inserted.text, caret: inserted.caret }, effects: [] };
|
|
323
|
+
}
|
|
324
|
+
default:
|
|
325
|
+
return { state, effects: [] };
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function reduceChoices(
|
|
330
|
+
state: KxmTuiPanelState,
|
|
331
|
+
section: KxmTuiSectionView,
|
|
332
|
+
field: KxmTuiField | undefined,
|
|
333
|
+
decoded: KxmTuiInput,
|
|
334
|
+
): KxmTuiPanelStep {
|
|
335
|
+
if (!field) return { state: { ...state, mode: "browse" }, effects: [] };
|
|
336
|
+
const choices = visibleChoices(field, state.filter);
|
|
337
|
+
switch (decoded.kind) {
|
|
338
|
+
case "escape":
|
|
339
|
+
return { state: { ...state, mode: "browse", filter: "", filtering: false, choiceIndex: 0 }, effects: [] };
|
|
340
|
+
case "up":
|
|
341
|
+
return { state: moveChoice(state, choices.length, -1), effects: [] };
|
|
342
|
+
case "down":
|
|
343
|
+
return { state: moveChoice(state, choices.length, 1), effects: [] };
|
|
344
|
+
case "pageUp":
|
|
345
|
+
return { state: moveChoice(state, choices.length, -8), effects: [] };
|
|
346
|
+
case "pageDown":
|
|
347
|
+
return { state: moveChoice(state, choices.length, 8), effects: [] };
|
|
348
|
+
case "enter": {
|
|
349
|
+
const choice = choiceAt(field, state);
|
|
350
|
+
if (!choice) return { state, effects: [] };
|
|
351
|
+
if (choice.status === "blocked") {
|
|
352
|
+
return { state, effects: [{ type: "notice", message: choice.statusText ?? `${choice.label} is unavailable` }] };
|
|
353
|
+
}
|
|
354
|
+
const clearing = (choice.action ?? KXM_TUI_SET_ACTION) === KXM_TUI_CLEAR_ACTION;
|
|
355
|
+
const step = invokeEffect(
|
|
356
|
+
state,
|
|
357
|
+
section,
|
|
358
|
+
field,
|
|
359
|
+
choice.action ?? KXM_TUI_SET_ACTION,
|
|
360
|
+
clearing ? undefined : choice.id,
|
|
361
|
+
);
|
|
362
|
+
return { state: { ...step.state, mode: "browse", filter: "", filtering: false }, effects: step.effects };
|
|
363
|
+
}
|
|
364
|
+
case "backspace": {
|
|
365
|
+
if (state.filter.length === 0) return { state: { ...state, filtering: false }, effects: [] };
|
|
366
|
+
return { state: { ...state, filter: deleteBackward(state.filter), choiceIndex: 0 }, effects: [] };
|
|
367
|
+
}
|
|
368
|
+
case "text": {
|
|
369
|
+
const text = normalizeInputText(decoded.text ?? "");
|
|
370
|
+
if (!text) return { state, effects: [] };
|
|
371
|
+
return { state: { ...state, filtering: true, filter: `${state.filter}${text}`, choiceIndex: 0 }, effects: [] };
|
|
372
|
+
}
|
|
373
|
+
default:
|
|
374
|
+
return { state, effects: [] };
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
function reduceBrowse(
|
|
379
|
+
state: KxmTuiPanelState,
|
|
380
|
+
sections: readonly KxmTuiSectionView[],
|
|
381
|
+
section: KxmTuiSectionView,
|
|
382
|
+
field: KxmTuiField | undefined,
|
|
383
|
+
decoded: KxmTuiInput,
|
|
384
|
+
): KxmTuiPanelStep {
|
|
385
|
+
if (state.help && (decoded.kind === "escape" || decoded.kind === "enter")) {
|
|
386
|
+
return { state: { ...state, help: false }, effects: [] };
|
|
387
|
+
}
|
|
388
|
+
if (state.help) return { state, effects: [] };
|
|
389
|
+
|
|
390
|
+
if (decoded.kind === "text") {
|
|
391
|
+
const key = decoded.text ?? "";
|
|
392
|
+
if (key === "q") return { state, effects: [{ type: "quit" }] };
|
|
393
|
+
if (key === "h" || key === "?") return { state: { ...state, help: true }, effects: [] };
|
|
394
|
+
const action = field?.actions?.find((candidate) => candidate.key === key);
|
|
395
|
+
if (action && field) return invokeEffect(state, section, field, action.action);
|
|
396
|
+
if ((key === "x" || key === "d") && field && field.value !== undefined && field.kind !== "info") {
|
|
397
|
+
return invokeEffect(state, section, field, KXM_TUI_CLEAR_ACTION);
|
|
398
|
+
}
|
|
399
|
+
if (PANE_KEYS.has(key)) {
|
|
400
|
+
if (key === "h") return { state: { ...state, pane: "sections" }, effects: [] };
|
|
401
|
+
if (key === "l") return { state: { ...state, pane: "fields" }, effects: [] };
|
|
402
|
+
const delta = key === "k" ? -1 : 1;
|
|
403
|
+
return {
|
|
404
|
+
state: state.pane === "fields" ? moveField(state, section.fields, delta) : moveSection(state, sections, delta),
|
|
405
|
+
effects: [],
|
|
406
|
+
};
|
|
407
|
+
}
|
|
408
|
+
return { state, effects: [] };
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
switch (decoded.kind) {
|
|
412
|
+
case "escape":
|
|
413
|
+
return state.pane === "fields"
|
|
414
|
+
? { state: { ...state, pane: "sections", mode: "browse" }, effects: [] }
|
|
415
|
+
: { state, effects: [{ type: "quit" }] };
|
|
416
|
+
case "tab":
|
|
417
|
+
return { state: { ...state, pane: state.pane === "fields" ? "sections" : "fields" }, effects: [] };
|
|
418
|
+
case "right":
|
|
419
|
+
return { state: { ...state, pane: "fields" }, effects: [] };
|
|
420
|
+
case "left":
|
|
421
|
+
return { state: { ...state, pane: "sections" }, effects: [] };
|
|
422
|
+
case "up":
|
|
423
|
+
return { state: state.pane === "fields" ? moveField(state, section.fields, -1) : moveSection(state, sections, -1), effects: [] };
|
|
424
|
+
case "down":
|
|
425
|
+
return { state: state.pane === "fields" ? moveField(state, section.fields, 1) : moveSection(state, sections, 1), effects: [] };
|
|
426
|
+
case "pageUp":
|
|
427
|
+
return { state: moveSection(state, sections, -1), effects: [] };
|
|
428
|
+
case "pageDown":
|
|
429
|
+
return { state: moveSection(state, sections, 1), effects: [] };
|
|
430
|
+
case "enter":
|
|
431
|
+
return state.pane === "fields" ? enterField(state, section, field) : { state: { ...state, pane: "fields" }, effects: [] };
|
|
432
|
+
default:
|
|
433
|
+
return { state, effects: [] };
|
|
434
|
+
}
|
|
435
|
+
}
|