heyspex 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +21 -0
- package/bin/hs.mjs +931 -98
- package/bin/spexcode.mjs +262 -0
- package/dev-factory/engine/worktree.mjs +740 -0
- package/dev-factory/lib/code-repo-root.mjs +67 -0
- package/dev-factory/lib/context-budget.mjs +84 -0
- package/dev-factory/lib/epic-detect.mjs +190 -0
- package/dev-factory/lib/fleet-queue-file.mjs +846 -0
- package/dev-factory/lib/fleet-queue.mjs +426 -0
- package/dev-factory/lib/fleet-state.mjs +14 -18
- package/dev-factory/lib/model-catalog.mjs +338 -0
- package/dev-factory/lib/seat-context.mjs +199 -0
- package/dev-factory/lib/spec-io.mjs +93 -0
- package/dev-factory/lib/sufficiency-score.mjs +8 -2
- package/dev-factory/lib/worktree-allocator.mjs +744 -0
- package/dev-factory/model-catalog.json +222 -0
- package/dist/cli/hs-check.mjs +1085 -103
- package/dist/cli/hs-claim.mjs +936 -63
- package/dist/cli/hs-connect.mjs +9786 -1910
- package/dist/cli/hs-defer.mjs +5267 -3053
- package/dist/cli/hs-dev.mjs +564 -0
- package/dist/cli/hs-env.mjs +385 -299
- package/dist/cli/hs-index-rebuild.mjs +7129 -0
- package/dist/cli/hs-index-reconcile.mjs +7852 -0
- package/dist/cli/hs-init.mjs +777 -138
- package/dist/cli/hs-install.mjs +6583 -485
- package/dist/cli/hs-join.mjs +10137 -2368
- package/dist/cli/hs-vault-pull.mjs +4659 -5882
- package/dist/cli/hs-vault.mjs +3793 -1537
- package/dist/cli/hs-workspace.mjs +2550 -524
- package/dist/cli-templates/customer-vault/specs/entity-model.md +594 -601
- package/dist/cli-templates/customer-vault/specs/protocols/canonical-frontmatter-templates.md +110 -177
- package/dist/cli-templates/customer-vault/specs/protocols/destructive-action-checklist.md +122 -0
- package/dist/cli-templates/customer-vault/specs/protocols/memory-protocol.md +13 -15
- package/dist/cli-templates/customer-vault/specs/protocols/session-close-protocol.md +91 -0
- package/dist/cli-templates/customer-vault/specs/protocols/spec-hierarchy.md +150 -168
- package/dist/cli-templates/customer-vault/specs/protocols/spec-summary-authoring.md +83 -0
- package/dist/cli-templates/harness-commands/claude/hs-auto.md +36 -0
- package/dist/cli-templates/harness-commands/claude/hs-backfill.md +28 -0
- package/dist/cli-templates/harness-commands/claude/hs-capture.md +62 -0
- package/dist/cli-templates/harness-commands/claude/hs-defer.md +29 -0
- package/dist/cli-templates/harness-commands/claude/hs-dev.md +20 -0
- package/dist/cli-templates/harness-commands/claude/hs-doctor.md +32 -0
- package/dist/cli-templates/harness-commands/claude/hs-ground.md +28 -0
- package/dist/cli-templates/harness-commands/claude/hs-handoff.md +33 -0
- package/dist/cli-templates/harness-commands/claude/hs-log.md +27 -0
- package/dist/cli-templates/harness-commands/claude/hs-next.md +29 -0
- package/dist/cli-templates/harness-commands/claude/hs-promote.md +70 -0
- package/dist/cli-templates/harness-commands/claude/hs-push.md +35 -0
- package/dist/cli-templates/harness-commands/claude/hs-qc.md +27 -0
- package/dist/cli-templates/harness-commands/claude/hs-res.md +63 -0
- package/dist/cli-templates/harness-commands/claude/hs-resolve.md +23 -0
- package/dist/cli-templates/harness-commands/claude/hs-review.md +23 -0
- package/dist/cli-templates/harness-commands/claude/hs-view.md +26 -0
- package/dist/cli-templates/harness-commands/claude/kill-ports.md +32 -0
- package/dist/cli-templates/harness-commands/claude/prototype.md +91 -0
- package/dist/cli-templates/harness-commands/cursor/hs-auto.md +40 -0
- package/dist/cli-templates/harness-commands/cursor/hs-backfill.md +31 -0
- package/dist/cli-templates/harness-commands/cursor/hs-capture.md +65 -0
- package/dist/cli-templates/harness-commands/cursor/hs-defer.md +32 -0
- package/dist/cli-templates/harness-commands/cursor/hs-dev.md +24 -0
- package/dist/cli-templates/harness-commands/cursor/hs-doctor.md +36 -0
- package/dist/cli-templates/harness-commands/cursor/hs-ground.md +31 -0
- package/dist/cli-templates/harness-commands/cursor/hs-handoff.md +37 -0
- package/dist/cli-templates/harness-commands/cursor/hs-log.md +31 -0
- package/dist/cli-templates/harness-commands/cursor/hs-next.md +32 -0
- package/dist/cli-templates/harness-commands/cursor/hs-promote.md +73 -0
- package/dist/cli-templates/harness-commands/cursor/hs-push.md +38 -0
- package/dist/cli-templates/harness-commands/cursor/hs-qc.md +30 -0
- package/dist/cli-templates/harness-commands/cursor/hs-res.md +67 -0
- package/dist/cli-templates/harness-commands/cursor/hs-resolve.md +26 -0
- package/dist/cli-templates/harness-commands/cursor/hs-review.md +26 -0
- package/dist/cli-templates/harness-commands/cursor/hs-view.md +30 -0
- package/dist/cli-templates/harness-commands/cursor/kill-ports.md +35 -0
- package/dist/cli-templates/harness-commands/cursor/prototype.md +95 -0
- package/docs/mcp-client-config.md +259 -6
- package/package.json +231 -124
- package/plugins/heyspex/.claude-plugin/plugin.json +12 -0
- package/plugins/heyspex/.cursor-plugin/plugin.json +15 -0
- package/plugins/heyspex/.mcp.json +12 -0
- package/plugins/heyspex/bin/launch-mcp.mjs +95 -0
- package/plugins/heyspex/bin/run-hook.mjs +62 -0
- package/plugins/heyspex/hooks/cursor-hooks.json +227 -0
- package/plugins/heyspex/hooks/hooks.json +361 -0
- package/plugins/heyspex/mcp.json +13 -0
- package/scripts/check-mcp-freshness.mjs +17 -9
- package/scripts/claude-hooks/_env-vault-events.mjs +68 -0
- package/scripts/claude-hooks/_guardrail-events.mjs +96 -0
- package/scripts/claude-hooks/_guardrail-loader.mjs +494 -0
- package/scripts/claude-hooks/_intent-classifier-router.mjs +410 -0
- package/scripts/claude-hooks/_intent-classifier-semantic.mjs +351 -0
- package/scripts/claude-hooks/_run.mjs +120 -0
- package/scripts/claude-hooks/_transcript-text.mjs +60 -0
- package/scripts/claude-hooks/_vault-retrieval.mjs +322 -0
- package/scripts/claude-hooks/agent-spawn-delegation-gate.mjs +133 -0
- package/scripts/claude-hooks/capture-workflow-gate.mjs +11 -0
- package/scripts/claude-hooks/check-auto-memory-budget.mjs +396 -0
- package/scripts/claude-hooks/check-claude-md-budget.mjs +327 -0
- package/scripts/claude-hooks/check-mcp-freshness-hook.mjs +14 -0
- package/scripts/claude-hooks/claim-before-code-gate.mjs +11 -0
- package/scripts/claude-hooks/claim-liveness-shared.mjs +575 -0
- package/scripts/claude-hooks/close-terminal-on-ship.mjs +64 -0
- package/scripts/claude-hooks/coo-denylist-gate.mjs +11 -0
- package/scripts/claude-hooks/coo-guard-check.mjs +11 -0
- package/scripts/claude-hooks/deep-research-cost-gate.mjs +11 -0
- package/scripts/claude-hooks/dev-server-task-reminder.mjs +15 -0
- package/scripts/claude-hooks/emit-claim-status.mjs +41 -0
- package/scripts/claude-hooks/enforce-session-sequence.mjs +11 -0
- package/scripts/claude-hooks/enforce-vault-naming.mjs +11 -0
- package/scripts/claude-hooks/env-vault-guard.mjs +11 -0
- package/scripts/claude-hooks/epic-milestone-phrasing-gate.mjs +11 -0
- package/scripts/claude-hooks/git-identity-guard.mjs +11 -0
- package/scripts/claude-hooks/git-reflog-audit.mjs +15 -0
- package/scripts/claude-hooks/git-stash-gate.mjs +11 -0
- package/scripts/claude-hooks/guard-auto-memory-writes.mjs +137 -0
- package/scripts/claude-hooks/id-display-format-gate.mjs +11 -0
- package/scripts/claude-hooks/inject-communication-style.mjs +66 -0
- package/scripts/claude-hooks/inject-coo-seat.mjs +78 -0
- package/scripts/claude-hooks/inject-dev-server-awareness.mjs +11 -0
- package/scripts/claude-hooks/inject-doctrine.mjs +13 -0
- package/scripts/claude-hooks/inject-vault-context.mjs +14 -0
- package/scripts/claude-hooks/intent-classifier-trigger.mjs +247 -0
- package/scripts/claude-hooks/log-session-activity.mjs +341 -0
- package/scripts/claude-hooks/naughty-list-gate.mjs +11 -0
- package/scripts/claude-hooks/notify-status-flip.mjs +72 -0
- package/scripts/claude-hooks/plan-exit-ingest-gate.mjs +170 -0
- package/scripts/claude-hooks/plan-ingest-close-reminder.mjs +66 -0
- package/scripts/claude-hooks/plan-review-gate.mjs +11 -0
- package/scripts/claude-hooks/preflight-reminder.mjs +262 -0
- package/scripts/claude-hooks/protected-action-intent.mjs +221 -0
- package/scripts/claude-hooks/research-method-guard.mjs +398 -0
- package/scripts/claude-hooks/research-protocol-gate.mjs +11 -0
- package/scripts/claude-hooks/session-end.mjs +449 -0
- package/scripts/claude-hooks/settings-write-gate.mjs +11 -0
- package/scripts/claude-hooks/settlement-check-gate.mjs +39 -0
- package/scripts/claude-hooks/status-flip-detect.mjs +17 -0
- package/scripts/claude-hooks/terminal-close-shared.mjs +426 -0
- package/scripts/claude-hooks/terminal-close-watcher.mjs +122 -0
- package/scripts/claude-hooks/wip-checkpoint.mjs +298 -0
- package/scripts/cursor-hooks/manifest.mjs +235 -0
- package/scripts/dev-server-status.mjs +34 -13
- package/scripts/dev-server-stop.mjs +313 -0
- package/scripts/heyspex-mode.mjs +268 -0
- package/scripts/hs-auto-resolve.mjs +240 -0
- package/scripts/hs-auto-source.mjs +398 -0
- package/scripts/hs-auto.mjs +134 -0
- package/scripts/hs-capture-cli.mjs +326 -0
- package/scripts/hs-classifier.mjs +136 -0
- package/scripts/hs-companion.mjs +88 -0
- package/scripts/hs-gateway.mjs +115 -0
- package/scripts/hs-mode.mjs +125 -0
- package/scripts/hs-next.mjs +441 -0
- package/scripts/hs-settings.mjs +338 -0
- package/scripts/hs-view.mjs +333 -0
- package/scripts/hs-worktree.mjs +201 -0
- package/scripts/install-claude-code-hooks.mjs +270 -0
- package/scripts/install-cursor-hooks.mjs +471 -0
- package/scripts/lib/agent-env-markers.mjs +38 -0
- package/scripts/lib/console-url-resolver.mjs +197 -60
- package/scripts/lib/customer-kit.mjs +886 -0
- package/scripts/lib/dev-mode.mjs +244 -28
- package/scripts/lib/heyspex-home.mjs +72 -0
- package/scripts/lib/hs-command.mjs +277 -0
- package/scripts/lib/hs-companion-settings.mjs +472 -0
- package/scripts/lib/hs-source-commit.mjs +59 -5
- package/scripts/lib/hs-source-identity.mjs +526 -0
- package/scripts/lib/hs-source-log.mjs +11 -21
- package/scripts/lib/hs-source-push.mjs +107 -58
- package/scripts/lib/hs-worktree-args.mjs +115 -0
- package/scripts/lib/shared-path-helpers.mjs +134 -0
- package/scripts/lib/standalone-doctor.mjs +540 -3
- package/scripts/lib/subagent-git-lock.mjs +109 -0
- package/scripts/lib/template-pin.mjs +34 -0
- package/scripts/provenance-lib.mjs +91 -0
- package/scripts/stamp-provenance-on-edit.mjs +222 -0
- package/scripts/wip-ledger-health.mjs +653 -0
- package/spexharness/doctrine/baseline/SPEXCODE-DOCTRINE.baseline.json +29 -0
- package/spexharness/src/adapters/audit.mjs +197 -0
- package/spexharness/src/adapters/claude-code.mjs +441 -0
- package/spexharness/src/chat/tui/ansi.mjs +448 -0
- package/spexharness/src/chat/tui/palette.mjs +22 -0
- package/spexharness/src/chat/tui/select.mjs +208 -0
- package/spexharness/src/config-root.mjs +54 -0
- package/spexharness/src/context-budget.mjs +141 -0
- package/spexharness/src/dev-server-control.mjs +389 -0
- package/spexharness/src/doctrine/behaviour.mjs +371 -0
- package/spexharness/src/doctrine/corpus.mjs +733 -0
- package/spexharness/src/doctrine/inject.mjs +338 -0
- package/spexharness/src/doctrine/merge.mjs +184 -0
- package/spexharness/src/doctrine/sources.mjs +252 -0
- package/spexharness/src/doctrine/synthesize.mjs +401 -0
- package/spexharness/src/doctrine/vault-source.mjs +574 -0
- package/spexharness/src/doctrine/writer.mjs +176 -0
- package/spexharness/src/events.mjs +84 -0
- package/spexharness/src/gates/capture-workflow-gate.mjs +202 -0
- package/spexharness/src/gates/claim-before-code-gate.decide.mjs +33 -0
- package/spexharness/src/gates/claim-before-code-gate.mjs +129 -0
- package/spexharness/src/gates/deep-research-cost-gate.mjs +274 -0
- package/spexharness/src/gates/enforce-session-sequence.mjs +895 -0
- package/spexharness/src/gates/enforce-vault-naming.mjs +215 -0
- package/spexharness/src/gates/epic-milestone-phrasing-gate.mjs +233 -0
- package/spexharness/src/gates/git-identity-guard.decide.mjs +436 -0
- package/spexharness/src/gates/git-identity-guard.mjs +61 -0
- package/spexharness/src/gates/git-reflog-audit.mjs +151 -0
- package/spexharness/src/gates/git-stash-gate.decide.mjs +361 -0
- package/spexharness/src/gates/git-stash-gate.mjs +159 -0
- package/spexharness/src/gates/id-display-format-gate.mjs +190 -0
- package/spexharness/src/gates/inject-doctrine-gate.mjs +89 -0
- package/spexharness/src/gates/inject-vault-context.mjs +239 -0
- package/spexharness/src/gates/naughty-list-gate.mjs +213 -0
- package/spexharness/src/gates/plan-review-gate.mjs +233 -0
- package/spexharness/src/gates/preflight-reminder-gate.mjs +265 -0
- package/spexharness/src/gates/research-protocol-gate.mjs +175 -0
- package/spexharness/src/gates/settings-write-gate.decide.mjs +96 -0
- package/spexharness/src/gates/settings-write-gate.mjs +104 -0
- package/spexharness/src/git-reflog-audit.mjs +294 -0
- package/spexharness/src/intent-classifier.mjs +194 -0
- package/spexharness/src/intent-directive.mjs +541 -0
- package/spexharness/src/profile/learn.mjs +551 -0
- package/spexharness/src/profile/proposals.mjs +357 -0
- package/spexharness/src/profile/vault-inclusion.mjs +386 -0
- package/spexharness/src/redact.mjs +151 -0
- package/spexharness/src/rule-table.mjs +819 -0
- package/spexharness/src/seat-context.mjs +122 -0
- package/spexharness/src/session-bootstrap.mjs +512 -0
- package/spexharness/src/session-record.mjs +815 -0
- package/spexharness/src/token-inventory.mjs +207 -0
- package/vendor/heyspex-mcp/account-switch.mjs +95 -0
- package/vendor/heyspex-mcp/alignment-gate.mjs +315 -1
- package/vendor/heyspex-mcp/blocker-resolver.mjs +37 -2
- package/vendor/heyspex-mcp/breakdown-ladder.mjs +119 -4
- package/vendor/heyspex-mcp/breakdown-source.mjs +271 -0
- package/vendor/heyspex-mcp/breakdown-writer.mjs +23 -3
- package/vendor/heyspex-mcp/capture-buffer-patches.mjs +46 -5
- package/vendor/heyspex-mcp/capture-channel.mjs +55 -5
- package/vendor/heyspex-mcp/capture-console-client.mjs +80 -1
- package/vendor/heyspex-mcp/capture-focus.mjs +1035 -29
- package/vendor/heyspex-mcp/capture-outbox.mjs +5 -1
- package/vendor/heyspex-mcp/capture-tools.mjs +1219 -0
- package/vendor/heyspex-mcp/claim-tools.mjs +1124 -0
- package/vendor/heyspex-mcp/cli-verbs.mjs +2076 -0
- package/vendor/heyspex-mcp/cli.mjs +201 -15524
- package/vendor/heyspex-mcp/cloud-tool-surface.mjs +128 -0
- package/vendor/heyspex-mcp/cloud-vault-writes.mjs +412 -0
- package/vendor/heyspex-mcp/config-seed.yaml +15 -0
- package/vendor/heyspex-mcp/config-settings.mjs +290 -7
- package/vendor/heyspex-mcp/console-fetch.mjs +657 -16
- package/vendor/heyspex-mcp/console-transport.mjs +166 -0
- package/vendor/heyspex-mcp/credentials.mjs +584 -0
- package/vendor/heyspex-mcp/defer-spec.mjs +44 -4
- package/vendor/heyspex-mcp/deferred-reclaim.mjs +29 -3
- package/vendor/heyspex-mcp/design-handlers.mjs +31 -0
- package/vendor/heyspex-mcp/design-tools.mjs +4 -1
- package/vendor/heyspex-mcp/dev-factory-bridge.mjs +4 -4
- package/vendor/heyspex-mcp/dev-mode-core.mjs +45 -0
- package/vendor/heyspex-mcp/dev-tools.mjs +124 -0
- package/vendor/heyspex-mcp/diagnose-handlers.mjs +54 -0
- package/vendor/heyspex-mcp/events-tools.mjs +207 -0
- package/vendor/heyspex-mcp/filing-tools.mjs +688 -0
- package/vendor/heyspex-mcp/fleet-handlers.mjs +37 -0
- package/vendor/heyspex-mcp/fleet-queue-tools.mjs +69 -14
- package/vendor/heyspex-mcp/focus-tools.mjs +190 -0
- package/vendor/heyspex-mcp/folder-trust-store.mjs +59 -0
- package/vendor/heyspex-mcp/http-transport.mjs +300 -0
- package/vendor/heyspex-mcp/list-tools.mjs +257 -0
- package/vendor/heyspex-mcp/login-handoff.mjs +15 -1
- package/vendor/heyspex-mcp/mcp-config.mjs +94 -0
- package/vendor/heyspex-mcp/memory-tools.mjs +523 -0
- package/vendor/heyspex-mcp/mvp-mode.mjs +53 -6
- package/vendor/heyspex-mcp/occupied-keys.mjs +141 -0
- package/vendor/heyspex-mcp/on-deck-brief.mjs +3360 -0
- package/vendor/heyspex-mcp/on-deck-scheduler.mjs +1708 -0
- package/vendor/heyspex-mcp/package-root.mjs +32 -0
- package/vendor/heyspex-mcp/peer-presence.mjs +8 -1
- package/vendor/heyspex-mcp/plan-ground.mjs +621 -652
- package/vendor/heyspex-mcp/plan-push.mjs +906 -21
- package/vendor/heyspex-mcp/plan-review.mjs +124 -30
- package/vendor/heyspex-mcp/preflight-gate.mjs +0 -0
- package/vendor/heyspex-mcp/procedure-handlers.mjs +325 -0
- package/vendor/heyspex-mcp/procedure-tools.mjs +540 -351
- package/vendor/heyspex-mcp/promote-capture.mjs +386 -69
- package/vendor/heyspex-mcp/promote-diagnose.mjs +74 -0
- package/vendor/heyspex-mcp/promote-research-to-diagnose.mjs +9 -2
- package/vendor/heyspex-mcp/promote-research.mjs +660 -53
- package/vendor/heyspex-mcp/promotion-helpers.mjs +251 -0
- package/vendor/heyspex-mcp/prompts.mjs +1460 -0
- package/vendor/heyspex-mcp/read-path.mjs +29 -9
- package/vendor/heyspex-mcp/receipt-shape.mjs +146 -0
- package/vendor/heyspex-mcp/recipe-handlers.mjs +117 -0
- package/vendor/heyspex-mcp/regen-tasks.mjs +210 -3
- package/vendor/heyspex-mcp/register-spec.mjs +266 -54
- package/vendor/heyspex-mcp/request-context.mjs +59 -0
- package/vendor/heyspex-mcp/research-focus.mjs +207 -4
- package/vendor/heyspex-mcp/research-handlers.mjs +623 -0
- package/vendor/heyspex-mcp/research-tools.mjs +4 -4
- package/vendor/heyspex-mcp/response-budget.mjs +348 -0
- package/vendor/heyspex-mcp/review-focus.mjs +25 -1
- package/vendor/heyspex-mcp/search-specs.mjs +280 -0
- package/vendor/heyspex-mcp/seat-block-ledger.mjs +113 -0
- package/vendor/heyspex-mcp/seat-identity.mjs +2 -6
- package/vendor/heyspex-mcp/session-owner.mjs +108 -0
- package/vendor/heyspex-mcp/session-tools.mjs +152 -0
- package/vendor/heyspex-mcp/settings-registry.mjs +232 -0
- package/vendor/heyspex-mcp/settings-tools.mjs +85 -12
- package/vendor/heyspex-mcp/spec-edit-tools.mjs +446 -0
- package/vendor/heyspex-mcp/spec-id-lib.mjs +93 -17
- package/vendor/heyspex-mcp/split-research.mjs +301 -56
- package/vendor/heyspex-mcp/stdio-rpc.mjs +22 -0
- package/vendor/heyspex-mcp/submit-for-review.mjs +132 -6
- package/vendor/heyspex-mcp/summary-contract.mjs +105 -0
- package/vendor/heyspex-mcp/summary-write-tools.mjs +224 -0
- package/vendor/heyspex-mcp/symptom-query.mjs +146 -0
- package/vendor/heyspex-mcp/test-helpers.mjs +108 -1
- package/vendor/heyspex-mcp/tool-catalog.mjs +2813 -0
- package/vendor/heyspex-mcp/tool-dispatch.mjs +639 -0
- package/vendor/heyspex-mcp/tool-helpers.mjs +325 -0
- package/vendor/heyspex-mcp/validate-linkage.mjs +414 -27
- package/vendor/heyspex-mcp/vault-io.mjs +724 -0
- package/vendor/heyspex-mcp/vault-less-endpoints.mjs +52 -0
- package/vendor/heyspex-mcp/vault-resolve.mjs +366 -68
- package/vendor/heyspex-mcp/verify-reproduction.mjs +270 -30
- package/vendor/heyspex-mcp/whoami.mjs +91 -0
|
@@ -0,0 +1,448 @@
|
|
|
1
|
+
// spexharness/src/chat/tui/ansi.mjs
|
|
2
|
+
//
|
|
3
|
+
// Pure ANSI/VT drawing primitives for the `hs chat` TUI presenter (HS-TJYK T1). No I/O —
|
|
4
|
+
// every helper here takes plain values and returns a string; the caller (status-bar.mjs,
|
|
5
|
+
// welcome.mjs, presenter.mjs, ...) decides when and where to write it. This posture matches
|
|
6
|
+
// every other spexharness/src/chat/*.mjs module (see chat-sigint.mjs's header).
|
|
7
|
+
//
|
|
8
|
+
// Sequences implemented (epic [[HS-TJYK-8CMW-EPIC-interactive-shell-parity-heyspex]] §T1):
|
|
9
|
+
// sgr(...codes) CSI codes 'm' e.g. sgr(1) -> ESC[1m
|
|
10
|
+
// fg(hex, depth) 24-bit / 256-color / no-op foreground color
|
|
11
|
+
// bold / dim / reset SGR 1 / 2 / 0
|
|
12
|
+
// cursorTo(row, col) CSI row;col H
|
|
13
|
+
// cursorUp(n) CSI n A — relative cursor move, up n rows (no scroll)
|
|
14
|
+
// cursorDown(n) CSI n B — relative cursor move, down n rows (no scroll)
|
|
15
|
+
// cursorToCol(col) CSI col G — absolute column, current row unchanged
|
|
16
|
+
// saveCursor/restoreCursor DECSC/DECRC — ESC 7 / ESC 8 (NOT CSI sequences)
|
|
17
|
+
// setScrollRegion(t, b) DECSTBM — CSI t;b r
|
|
18
|
+
// resetScrollRegion CSI r
|
|
19
|
+
// clearLine CSI 2K
|
|
20
|
+
// clearScreenDown CSI J (erase from cursor to end of screen)
|
|
21
|
+
// enableBracketedPaste CSI ?2004 h
|
|
22
|
+
// disableBracketedPaste CSI ?2004 l
|
|
23
|
+
// hideCursor/showCursor CSI ?25l / CSI ?25h
|
|
24
|
+
// pickColorDepth(stdout, env) 24 | 8 | 0
|
|
25
|
+
// visibleWidth(text) terminal columns `text` occupies — Unicode/grapheme-aware, CSI ignored
|
|
26
|
+
// truncateVisible(text, cols) cut to `cols` columns without splitting a grapheme or an escape
|
|
27
|
+
//
|
|
28
|
+
// `fg`'s 256-color path uses the standard xterm 6x6x6 color-cube + grayscale-ramp mapping
|
|
29
|
+
// (the same nearest-approximation formula used by chalk/ansi-styles): each RGB channel is
|
|
30
|
+
// quantized to a 0-5 cube step, or — when r === g === b — mapped onto the 24-step grayscale
|
|
31
|
+
// ramp (indices 232-255) instead, since that ramp resolves neutrals far better than the
|
|
32
|
+
// color cube's corners. This is a reasonable-default choice the task spec left open; no other
|
|
33
|
+
// convention exists elsewhere in this repo to match.
|
|
34
|
+
|
|
35
|
+
/** ESC (0x1b). Exported so callers building their own raw sequences share one constant. */
|
|
36
|
+
export const ESC = '';
|
|
37
|
+
|
|
38
|
+
/** CSI <codes joined by ';'> m — e.g. sgr(1, 32) -> ESC[1;32m. sgr() with no args -> ESC[m. */
|
|
39
|
+
export function sgr(...codes) {
|
|
40
|
+
return `${ESC}[${codes.join(';')}m`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** SGR bold (1), dim (2), and the reset-all sequence (0). Constants, not functions — the
|
|
44
|
+
* other named sequences below (cursorTo, setScrollRegion, ...) are functions only because
|
|
45
|
+
* they take arguments; these three don't, so they stay plain strings for callers to inline. */
|
|
46
|
+
export const bold = sgr(1);
|
|
47
|
+
export const dim = sgr(2);
|
|
48
|
+
export const reset = sgr(0);
|
|
49
|
+
|
|
50
|
+
function hexToRgb(hex) {
|
|
51
|
+
const clean = hex.startsWith('#') ? hex.slice(1) : hex;
|
|
52
|
+
const r = parseInt(clean.slice(0, 2), 16);
|
|
53
|
+
const g = parseInt(clean.slice(2, 4), 16);
|
|
54
|
+
const b = parseInt(clean.slice(4, 6), 16);
|
|
55
|
+
return { r, g, b };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Nearest xterm-256 palette index for an RGB triple — 6x6x6 color cube (16-231) with a
|
|
59
|
+
* 24-step grayscale ramp (232-255) used for near-neutral colors. Standard chalk/ansi-styles
|
|
60
|
+
* formula. */
|
|
61
|
+
function rgbToAnsi256(r, g, b) {
|
|
62
|
+
if (r === g && g === b) {
|
|
63
|
+
if (r < 8) return 16;
|
|
64
|
+
if (r > 248) return 231;
|
|
65
|
+
return Math.round(((r - 8) / 247) * 24) + 232;
|
|
66
|
+
}
|
|
67
|
+
return (
|
|
68
|
+
16 +
|
|
69
|
+
36 * Math.round((r / 255) * 5) +
|
|
70
|
+
6 * Math.round((g / 255) * 5) +
|
|
71
|
+
Math.round((b / 255) * 5)
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Foreground color escape for a hex color at a given color depth.
|
|
77
|
+
* depth 24 -> CSI 38;2;R;G;B m (truecolor)
|
|
78
|
+
* depth 8 -> CSI 38;5;<nearest xterm-256 index> m
|
|
79
|
+
* depth 0 -> '' (no color support — caller should render plain text)
|
|
80
|
+
*/
|
|
81
|
+
export function fg(hex, depth) {
|
|
82
|
+
if (depth === 24) {
|
|
83
|
+
const { r, g, b } = hexToRgb(hex);
|
|
84
|
+
return `${ESC}[38;2;${r};${g};${b}m`;
|
|
85
|
+
}
|
|
86
|
+
if (depth === 8) {
|
|
87
|
+
const { r, g, b } = hexToRgb(hex);
|
|
88
|
+
return `${ESC}[38;5;${rgbToAnsi256(r, g, b)}m`;
|
|
89
|
+
}
|
|
90
|
+
return '';
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Background color escape for a hex color at a given color depth — same three-way split as
|
|
95
|
+
* `fg` (depth 24 truecolor / depth 8 nearest-256 / depth 0 no-op), CSI 48 instead of 38.
|
|
96
|
+
* input-box.mjs's submitted-input transcript line uses this for its "highlighted" `❯ text`
|
|
97
|
+
* segment (HS-05L9 T10, dock-anchored layout).
|
|
98
|
+
*/
|
|
99
|
+
export function bg(hex, depth) {
|
|
100
|
+
if (depth === 24) {
|
|
101
|
+
const { r, g, b } = hexToRgb(hex);
|
|
102
|
+
return `${ESC}[48;2;${r};${g};${b}m`;
|
|
103
|
+
}
|
|
104
|
+
if (depth === 8) {
|
|
105
|
+
const { r, g, b } = hexToRgb(hex);
|
|
106
|
+
return `${ESC}[48;5;${rgbToAnsi256(r, g, b)}m`;
|
|
107
|
+
}
|
|
108
|
+
return '';
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** CSI row;col H — absolute cursor position (1-indexed, as the terminal expects). */
|
|
112
|
+
export function cursorTo(row, col) {
|
|
113
|
+
return `${ESC}[${row};${col}H`;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** CSI n A — move the cursor UP n rows (default 1), relative to its current position.
|
|
117
|
+
* Never scrolls (unlike a bare '\n' at the bottom row) — status-bar.mjs's DECSTBM-homing
|
|
118
|
+
* fix pairs this with a leading '\n' precisely to get that "scroll only if already at the
|
|
119
|
+
* last row" behavior; cursorUp alone never scrolls either way. */
|
|
120
|
+
export function cursorUp(n = 1) {
|
|
121
|
+
return `${ESC}[${n}A`;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** CSI n B — move the cursor DOWN n rows (default 1), relative to its current position. The
|
|
125
|
+
* mirror image of `cursorUp` — HS-U4AJ (T11)'s slash-menu wiring in input-box.mjs uses this
|
|
126
|
+
* to reverse `positionOnActiveRow()`'s upward move (go from the active editing row back down
|
|
127
|
+
* to the dock's own resting position) before a menu-driven mid-edit dock rewrite. Never
|
|
128
|
+
* scrolls, same as `cursorUp`. */
|
|
129
|
+
export function cursorDown(n = 1) {
|
|
130
|
+
return `${ESC}[${n}B`;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** CSI col G — move the cursor to an absolute COLUMN on the current row (row unchanged). */
|
|
134
|
+
export function cursorToCol(col) {
|
|
135
|
+
return `${ESC}[${col}G`;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** DECSC — save cursor position (ESC 7, not a CSI sequence). */
|
|
139
|
+
export const saveCursor = `${ESC}7`;
|
|
140
|
+
|
|
141
|
+
/** DECRC — restore cursor position (ESC 8, not a CSI sequence). */
|
|
142
|
+
export const restoreCursor = `${ESC}8`;
|
|
143
|
+
|
|
144
|
+
/** DECSTBM — set the scrolling region to rows top..bottom (CSI top;bottom r). */
|
|
145
|
+
export function setScrollRegion(top, bottom) {
|
|
146
|
+
return `${ESC}[${top};${bottom}r`;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** DECSTBM with no operands — reset the scrolling region to the full screen. */
|
|
150
|
+
export const resetScrollRegion = `${ESC}[r`;
|
|
151
|
+
|
|
152
|
+
/** CSI 2K — clear the entire current line (cursor position unchanged). */
|
|
153
|
+
export const clearLine = `${ESC}[2K`;
|
|
154
|
+
|
|
155
|
+
/** CSI J — erase from the cursor to the end of the screen (cursor position unchanged). */
|
|
156
|
+
export const clearScreenDown = `${ESC}[J`;
|
|
157
|
+
|
|
158
|
+
/** CSI 2J (erase the entire visible screen) + CSI 3J (erase the terminal's own scrollback —
|
|
159
|
+
* an xterm extension, harmless where unsupported) + CSI H (home the cursor to row 1, col 1).
|
|
160
|
+
* The `/clear` built-in's sequence (HS-MW9H, T12) — clears what mouse-wheel/Page Up would
|
|
161
|
+
* still scroll back to, not just the current viewport, then homes the cursor so whatever
|
|
162
|
+
* redraws next (the dock) starts at the top-left. `builtins.mjs` writes this through
|
|
163
|
+
* `presenter.notice()`, never straight to `stdout`, so the TTY presenter's dock still gets
|
|
164
|
+
* its own redraw immediately after (dock.mjs's `writeAbove` contract). */
|
|
165
|
+
export const clearScreen = `${ESC}[2J${ESC}[3J${ESC}[H`;
|
|
166
|
+
|
|
167
|
+
/** Newlines enough to walk the cursor from row 1 to the LAST row of a `rows`-tall viewport.
|
|
168
|
+
*
|
|
169
|
+
* WHY (2026-09-14, owner report: "the text box should be anchored to the bottom of the screen
|
|
170
|
+
* like Claude Code"): the dock (dock.mjs) is a FLOW renderer by design — it has no scroll
|
|
171
|
+
* region and no absolute positioning, so it renders wherever the cursor happens to be, i.e.
|
|
172
|
+
* immediately under the transcript. On a fresh session that transcript is only the welcome
|
|
173
|
+
* header, so on a tall terminal the input box + status bar floated in the middle of the
|
|
174
|
+
* screen with dead space below them and a caret parked in the void.
|
|
175
|
+
*
|
|
176
|
+
* The fix needs no layout engine: fill the viewport ONCE at session open so the cursor is
|
|
177
|
+
* already sitting on the last row before the header is written. Every write from then on
|
|
178
|
+
* scrolls the screen instead of advancing down it, which pins the dock's last line to the
|
|
179
|
+
* bottom row for the whole session — the header and transcript grow UPWARD out of the bottom,
|
|
180
|
+
* exactly the shape Claude Code has. Blank rows fall off the top as real content arrives.
|
|
181
|
+
*
|
|
182
|
+
* Deliberately newlines rather than `cursorTo(rows, 1)`: a newline on the last row scrolls on
|
|
183
|
+
* every terminal there is, whereas absolute positioning is the class of sequence this whole
|
|
184
|
+
* renderer was rewritten to avoid depending on (see dock.mjs's header).
|
|
185
|
+
*
|
|
186
|
+
* @param {number} rows - the terminal's height (`stdout.rows`). Non-finite/<= 1 yields ''.
|
|
187
|
+
*/
|
|
188
|
+
export function fillViewport(rows) {
|
|
189
|
+
const n = Number(rows);
|
|
190
|
+
if (!Number.isFinite(n) || n <= 1) return '';
|
|
191
|
+
return '\n'.repeat(Math.floor(n) - 1);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** CSI ?2004 h / l — enable/disable bracketed-paste reporting (DECSET/DECRST 2004). Opportunistic
|
|
195
|
+
* per the T9 research recommendation: emit it, parse the markers if they arrive, never depend
|
|
196
|
+
* on it (conhost support is unsettled; Windows Terminal honours it when announced). */
|
|
197
|
+
export const enableBracketedPaste = `${ESC}[?2004h`;
|
|
198
|
+
export const disableBracketedPaste = `${ESC}[?2004l`;
|
|
199
|
+
|
|
200
|
+
/** CSI ?25l — hide the text cursor. */
|
|
201
|
+
export const hideCursor = `${ESC}[?25l`;
|
|
202
|
+
|
|
203
|
+
/** CSI ?25h — show the text cursor. */
|
|
204
|
+
export const showCursor = `${ESC}[?25h`;
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Pick the color depth to render at: 24 (truecolor), 8 (256-color), or 0 (no color).
|
|
208
|
+
*
|
|
209
|
+
* @param {{ isTTY?: boolean, hasColors?: (n: number) => boolean }} stdout
|
|
210
|
+
* @param {Record<string, string | undefined>} env
|
|
211
|
+
* @returns {24 | 8 | 0}
|
|
212
|
+
*/
|
|
213
|
+
// --- Visible width (Unicode-aware column counting) -----------------------------------------
|
|
214
|
+
//
|
|
215
|
+
// The shell-parity epic (HS-TJYK) left "Unicode width on Windows Terminal/conhost" as an
|
|
216
|
+
// unsettled test target: `truncateVisible` used to count one column per UTF-16 code unit, so
|
|
217
|
+
// any line carrying CJK, emoji, combining marks or the U+E000 HeySpex glyph was cut at the
|
|
218
|
+
// wrong column — and because dock.mjs's erase-by-height math trusts the truncated line to fit
|
|
219
|
+
// in `stdout.columns`, a line that then wrapped left a stale row behind on every redraw.
|
|
220
|
+
//
|
|
221
|
+
// What counts as what (`visibleWidth`):
|
|
222
|
+
// CSI escape sequences (`ESC [ params intermediates final`, SGR included) 0 — stepped over
|
|
223
|
+
// East Asian Wide / Fullwidth code points (CJK, Hangul syllables, kana,
|
|
224
|
+
// fullwidth forms, wide symbols/emoji — compact range table below, plus
|
|
225
|
+
// the engine's own `\p{Emoji_Presentation}` so new emoji stay wide) 2
|
|
226
|
+
// Combining marks (Mn/Me/Mc), format chars (Cf: ZWJ, ZWNJ, ZWSP, BOM,
|
|
227
|
+
// word joiner...), variation selectors, Hangul Jungseong/Jongseong jamo 0
|
|
228
|
+
// Private-use code points (U+E000–U+F8FF, planes 15/16) 1 — the
|
|
229
|
+
// HeySpex icon font renders them single-cell; they are EAW "Ambiguous", so
|
|
230
|
+
// they never appear in the wide table
|
|
231
|
+
// Everything else (ASCII, Latin, controls, ...) 1
|
|
232
|
+
//
|
|
233
|
+
// Widths are summed per GRAPHEME, not per code point: `Intl.Segmenter` (granularity
|
|
234
|
+
// 'grapheme', Node 24) groups an emoji ZWJ sequence (👨👩👧), a base + combining accent (é as
|
|
235
|
+
// e + U+0301), a flag (regional-indicator pair) or a keycap into one cluster, whose width is
|
|
236
|
+
// the widest of its parts — so a family emoji is ONE 2-column grapheme, `e + U+0301` is 1, and
|
|
237
|
+
// a text-default symbol forced to emoji presentation by VS16 (❤ + U+FE0F) is 2, matching
|
|
238
|
+
// what Windows Terminal actually draws. A grapheme that is pure ASCII (e.g. "\r\n", which the
|
|
239
|
+
// segmenter also clusters) keeps one column per code unit, so plain-ASCII behavior is exactly
|
|
240
|
+
// what it was before this landed.
|
|
241
|
+
|
|
242
|
+
/** One complete CSI sequence at the start of the string: ESC `[`, parameter bytes (0x30–0x3F),
|
|
243
|
+
* intermediate bytes (0x20–0x2F), one final byte (0x40–0x7E). SGR (`ESC[...m`) is the only
|
|
244
|
+
* shape status-bar.mjs / dock.mjs actually wrap text in, but any CSI is stepped over whole
|
|
245
|
+
* so a truncation point can never land mid-escape. Other ESC-prefixed shapes (DECSC `ESC 7`,
|
|
246
|
+
* OSC) are never part of line content and fall through as ordinary characters. */
|
|
247
|
+
const CSI_RE = /^\x1b\[[0-?]*[ -/]*[@-~]/;
|
|
248
|
+
const CSI_RE_G = /\x1b\[[0-?]*[ -/]*[@-~]/g;
|
|
249
|
+
|
|
250
|
+
/** Sorted, non-overlapping [first, last] ranges of East Asian Width W (Wide) and F (Fullwidth)
|
|
251
|
+
* code points (EastAsianWidth.txt, Unicode 15.1), merged where adjacent. Looked up by binary
|
|
252
|
+
* search. Ambiguous ("A") widths — box drawing, Greek/Cyrillic, the private-use area — are
|
|
253
|
+
* deliberately NOT here: Windows Terminal and conhost render them single-cell. */
|
|
254
|
+
const WIDE_RANGES = [
|
|
255
|
+
[0x1100, 0x115f], // Hangul Jamo (choseong)
|
|
256
|
+
[0x231a, 0x231b], [0x2329, 0x232a], [0x23e9, 0x23ec], [0x23f0, 0x23f0], [0x23f3, 0x23f3],
|
|
257
|
+
[0x25fd, 0x25fe], [0x2614, 0x2615], [0x2648, 0x2653], [0x267f, 0x267f], [0x2693, 0x2693],
|
|
258
|
+
[0x26a1, 0x26a1], [0x26aa, 0x26ab], [0x26bd, 0x26be], [0x26c4, 0x26c5], [0x26ce, 0x26ce],
|
|
259
|
+
[0x26d4, 0x26d4], [0x26ea, 0x26ea], [0x26f2, 0x26f3], [0x26f5, 0x26f5], [0x26fa, 0x26fa],
|
|
260
|
+
[0x26fd, 0x26fd], [0x2705, 0x2705], [0x270a, 0x270b], [0x2728, 0x2728], [0x274c, 0x274c],
|
|
261
|
+
[0x274e, 0x274e], [0x2753, 0x2755], [0x2757, 0x2757], [0x2795, 0x2797], [0x27b0, 0x27b0],
|
|
262
|
+
[0x27bf, 0x27bf], [0x2b1b, 0x2b1c], [0x2b50, 0x2b50], [0x2b55, 0x2b55],
|
|
263
|
+
[0x2e80, 0x2e99], [0x2e9b, 0x2ef3], [0x2f00, 0x2fd5], [0x2ff0, 0x2fff], // CJK radicals, IDC
|
|
264
|
+
[0x3000, 0x303e], [0x3041, 0x3096], [0x3099, 0x30ff], // CJK punctuation, hiragana, katakana
|
|
265
|
+
[0x3105, 0x312f], [0x3131, 0x318e], [0x3190, 0x31e5], [0x31ef, 0x321e], [0x3220, 0x3247],
|
|
266
|
+
[0x3250, 0x4dbf], [0x4e00, 0xa48c], [0xa490, 0xa4c6], [0xa960, 0xa97c], // CJK unified, Yi
|
|
267
|
+
[0xac00, 0xd7a3], // Hangul syllables
|
|
268
|
+
[0xf900, 0xfaff], [0xfe10, 0xfe19], [0xfe30, 0xfe52], [0xfe54, 0xfe66], [0xfe68, 0xfe6b],
|
|
269
|
+
[0xff01, 0xff60], [0xffe0, 0xffe6], // fullwidth forms
|
|
270
|
+
[0x16fe0, 0x16fe4], [0x16ff0, 0x16ff1], [0x17000, 0x187f7], [0x18800, 0x18cd5],
|
|
271
|
+
[0x18cff, 0x18d08], [0x1aff0, 0x1aff3], [0x1aff5, 0x1affb], [0x1affd, 0x1affe],
|
|
272
|
+
[0x1b000, 0x1b122], [0x1b132, 0x1b132], [0x1b150, 0x1b152], [0x1b155, 0x1b155],
|
|
273
|
+
[0x1b164, 0x1b167], [0x1b170, 0x1b2fb], // Tangut, Khitan, Nushu, kana supplements
|
|
274
|
+
[0x1f004, 0x1f004], [0x1f0cf, 0x1f0cf], [0x1f18e, 0x1f18e], [0x1f191, 0x1f19a],
|
|
275
|
+
[0x1f200, 0x1f202], [0x1f210, 0x1f23b], [0x1f240, 0x1f248], [0x1f250, 0x1f251],
|
|
276
|
+
[0x1f260, 0x1f265], [0x1f300, 0x1f320], [0x1f32d, 0x1f335], [0x1f337, 0x1f37c],
|
|
277
|
+
[0x1f37e, 0x1f393], [0x1f3a0, 0x1f3ca], [0x1f3cf, 0x1f3d3], [0x1f3e0, 0x1f3f0],
|
|
278
|
+
[0x1f3f4, 0x1f3f4], [0x1f3f8, 0x1f43e], [0x1f440, 0x1f440], [0x1f442, 0x1f4fc],
|
|
279
|
+
[0x1f4ff, 0x1f53d], [0x1f54b, 0x1f54e], [0x1f550, 0x1f567], [0x1f57a, 0x1f57a],
|
|
280
|
+
[0x1f595, 0x1f596], [0x1f5a4, 0x1f5a4], [0x1f5fb, 0x1f64f], [0x1f680, 0x1f6c5],
|
|
281
|
+
[0x1f6cc, 0x1f6cc], [0x1f6d0, 0x1f6d2], [0x1f6d5, 0x1f6d7], [0x1f6dc, 0x1f6df],
|
|
282
|
+
[0x1f6eb, 0x1f6ec], [0x1f6f4, 0x1f6fc], [0x1f7e0, 0x1f7eb], [0x1f7f0, 0x1f7f0],
|
|
283
|
+
[0x1f90c, 0x1f93a], [0x1f93c, 0x1f945], [0x1f947, 0x1f9ff], [0x1fa70, 0x1fa7c],
|
|
284
|
+
[0x1fa80, 0x1fa89], [0x1fa90, 0x1fabe], [0x1fabf, 0x1fac6], [0x1face, 0x1fadc],
|
|
285
|
+
[0x1fae0, 0x1fae9], [0x1faf0, 0x1faf8], // emoji blocks
|
|
286
|
+
[0x20000, 0x2fffd], [0x30000, 0x3fffd], // CJK extensions B+
|
|
287
|
+
];
|
|
288
|
+
|
|
289
|
+
/** Emoji the Unicode data says default to emoji (2-cell) presentation. The engine's own
|
|
290
|
+
* property tables (ICU) — a forward-compat net for emoji minted after WIDE_RANGES was written. */
|
|
291
|
+
const EMOJI_PRESENTATION_RE = /^\p{Emoji_Presentation}$/u;
|
|
292
|
+
|
|
293
|
+
/** Code points that occupy no column of their own: nonspacing/enclosing/spacing combining marks
|
|
294
|
+
* (Mn/Me/Mc — includes every variation selector U+FE00–FE0F and U+E0100–E01EF), format
|
|
295
|
+
* characters (Cf — ZWJ U+200D, ZWNJ, ZWSP, BOM, tag characters used by subdivision flags...). */
|
|
296
|
+
const ZERO_WIDTH_RE = /^[\p{Mn}\p{Me}\p{Mc}\p{Cf}]$/u;
|
|
297
|
+
|
|
298
|
+
/** Extended_Pictographic base + VS16 (U+FE0F) = forced emoji presentation, 2 cells. */
|
|
299
|
+
const PICTOGRAPHIC_RE = /^\p{Extended_Pictographic}$/u;
|
|
300
|
+
const REGIONAL_INDICATOR_RE = /^\p{Regional_Indicator}$/u;
|
|
301
|
+
|
|
302
|
+
let graphemeSegmenter = null;
|
|
303
|
+
function segmenter() {
|
|
304
|
+
if (graphemeSegmenter === null) {
|
|
305
|
+
graphemeSegmenter = new Intl.Segmenter(undefined, { granularity: 'grapheme' });
|
|
306
|
+
}
|
|
307
|
+
return graphemeSegmenter;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function isWideCodePoint(cp) {
|
|
311
|
+
if (cp < 0x1100) return false;
|
|
312
|
+
let lo = 0;
|
|
313
|
+
let hi = WIDE_RANGES.length - 1;
|
|
314
|
+
while (lo <= hi) {
|
|
315
|
+
const mid = (lo + hi) >> 1;
|
|
316
|
+
const [first, last] = WIDE_RANGES[mid];
|
|
317
|
+
if (cp < first) hi = mid - 1;
|
|
318
|
+
else if (cp > last) lo = mid + 1;
|
|
319
|
+
else return true;
|
|
320
|
+
}
|
|
321
|
+
return EMOJI_PRESENTATION_RE.test(String.fromCodePoint(cp));
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function isZeroWidthCodePoint(cp) {
|
|
325
|
+
if (cp < 0x300) return false; // ASCII + Latin-1: always one column (matches the old rule)
|
|
326
|
+
if (cp >= 0x1160 && cp <= 0x11ff) return true; // Hangul Jungseong/Jongseong (conjoining jamo)
|
|
327
|
+
if (cp >= 0xd7b0 && cp <= 0xd7ff) return true; // Hangul Jamo Extended-B (same)
|
|
328
|
+
return ZERO_WIDTH_RE.test(String.fromCodePoint(cp));
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/** Column width of ONE grapheme cluster (no escape sequences inside). */
|
|
332
|
+
function graphemeWidth(g) {
|
|
333
|
+
// Pure-ASCII cluster (a single char, or "\r\n"): one column per code unit — the pre-Unicode
|
|
334
|
+
// rule, preserved byte-for-byte.
|
|
335
|
+
let ascii = true;
|
|
336
|
+
for (let i = 0; i < g.length; i += 1) {
|
|
337
|
+
if (g.charCodeAt(i) > 0x7f) { ascii = false; break; }
|
|
338
|
+
}
|
|
339
|
+
if (ascii) return g.length;
|
|
340
|
+
|
|
341
|
+
let width = 0;
|
|
342
|
+
let sawVS16 = false;
|
|
343
|
+
let sawPictographic = false;
|
|
344
|
+
for (const ch of g) {
|
|
345
|
+
const cp = ch.codePointAt(0);
|
|
346
|
+
if (cp === 0xfe0f) { sawVS16 = true; continue; }
|
|
347
|
+
if (cp === 0x20e3) return 2; // COMBINING ENCLOSING KEYCAP: 1️⃣ renders 2 cells
|
|
348
|
+
if (isZeroWidthCodePoint(cp)) continue;
|
|
349
|
+
if (REGIONAL_INDICATOR_RE.test(ch)) return 2; // flag pair (or a lone RI) — one wide cell
|
|
350
|
+
if (isWideCodePoint(cp)) { width = 2; continue; }
|
|
351
|
+
if (!sawPictographic && PICTOGRAPHIC_RE.test(ch)) sawPictographic = true;
|
|
352
|
+
if (width < 1) width = 1;
|
|
353
|
+
}
|
|
354
|
+
if (sawVS16 && sawPictographic) return 2;
|
|
355
|
+
return width;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/** Split `text` into alternating plain / escape tokens: `{ esc: true, s }` for each CSI
|
|
359
|
+
* sequence, `{ esc: false, s }` for the plain runs between them (each run then segmented
|
|
360
|
+
* into graphemes by the caller). */
|
|
361
|
+
function* tokens(text) {
|
|
362
|
+
CSI_RE_G.lastIndex = 0;
|
|
363
|
+
let last = 0;
|
|
364
|
+
let m;
|
|
365
|
+
while ((m = CSI_RE_G.exec(text)) !== null) {
|
|
366
|
+
if (m.index > last) yield { esc: false, s: text.slice(last, m.index) };
|
|
367
|
+
yield { esc: true, s: m[0] };
|
|
368
|
+
last = m.index + m[0].length;
|
|
369
|
+
}
|
|
370
|
+
if (last < text.length) yield { esc: false, s: text.slice(last) };
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function* graphemes(run) {
|
|
374
|
+
// Fast path: an all-ASCII run is one grapheme per code unit (the pre-Unicode rule) — skip
|
|
375
|
+
// the segmenter, which would otherwise fuse "\r\n" and change plain-ASCII results.
|
|
376
|
+
let ascii = true;
|
|
377
|
+
for (let i = 0; i < run.length; i += 1) {
|
|
378
|
+
if (run.charCodeAt(i) > 0x7f) { ascii = false; break; }
|
|
379
|
+
}
|
|
380
|
+
if (ascii) {
|
|
381
|
+
for (let i = 0; i < run.length; i += 1) yield run[i];
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
384
|
+
for (const { segment } of segmenter().segment(run)) yield segment;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* Number of terminal columns `text` occupies when printed — CSI escape sequences ignored,
|
|
389
|
+
* East Asian Wide/Fullwidth graphemes (and emoji, including ZWJ sequences) 2, combining marks /
|
|
390
|
+
* format characters / variation selectors 0, private-use glyphs (U+E000–F8FF) 1, everything
|
|
391
|
+
* else 1. See the section header above for the full table.
|
|
392
|
+
*/
|
|
393
|
+
export function visibleWidth(text) {
|
|
394
|
+
const s = String(text ?? '');
|
|
395
|
+
let width = 0;
|
|
396
|
+
for (const tok of tokens(s)) {
|
|
397
|
+
if (tok.esc) continue;
|
|
398
|
+
for (const g of graphemes(tok.s)) width += graphemeWidth(g);
|
|
399
|
+
}
|
|
400
|
+
return width;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* Truncate `text` to `cols` VISIBLE columns — ANSI CSI/SGR sequences are copied through in full
|
|
405
|
+
* and never counted, and never split, so a truncation point can never land mid-escape; and the
|
|
406
|
+
* cut is grapheme-aware and width-aware (`visibleWidth`'s rules), so it never lands inside an
|
|
407
|
+
* emoji ZWJ sequence or between a base and its combining mark, and a 2-column character that
|
|
408
|
+
* does not fit in the remaining space is dropped whole rather than half-drawn.
|
|
409
|
+
* Moved here from status-bar.mjs (HS-05L9 T10) so dock.mjs can share the same primitive —
|
|
410
|
+
* every line dock.mjs renders goes through this, not just the status bar's.
|
|
411
|
+
*
|
|
412
|
+
* Escape sequences immediately following the last kept grapheme are still copied (so a
|
|
413
|
+
* trailing `reset` survives when the cut lands exactly at the end of a colored run); the walk
|
|
414
|
+
* stops at the first grapheme that does not fit.
|
|
415
|
+
*/
|
|
416
|
+
export function truncateVisible(text, cols) {
|
|
417
|
+
if (!(cols > 0)) return '';
|
|
418
|
+
let visible = 0;
|
|
419
|
+
let out = '';
|
|
420
|
+
for (const tok of tokens(text)) {
|
|
421
|
+
if (tok.esc) {
|
|
422
|
+
out += tok.s;
|
|
423
|
+
continue;
|
|
424
|
+
}
|
|
425
|
+
for (const g of graphemes(tok.s)) {
|
|
426
|
+
const w = graphemeWidth(g);
|
|
427
|
+
if (visible >= cols || visible + w > cols) return out;
|
|
428
|
+
out += g;
|
|
429
|
+
visible += w;
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
return out;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
export function pickColorDepth(stdout, env) {
|
|
436
|
+
if (env.NO_COLOR !== undefined) return 0;
|
|
437
|
+
if (!stdout || !stdout.isTTY) return 0;
|
|
438
|
+
|
|
439
|
+
const hasColors = typeof stdout.hasColors === 'function' ? stdout.hasColors.bind(stdout) : null;
|
|
440
|
+
|
|
441
|
+
if ((hasColors && hasColors(16777216)) || env.WT_SESSION !== undefined || env.COLORTERM === 'truecolor' || env.COLORTERM === '24bit') {
|
|
442
|
+
return 24;
|
|
443
|
+
}
|
|
444
|
+
if (hasColors && hasColors(256)) {
|
|
445
|
+
return 8;
|
|
446
|
+
}
|
|
447
|
+
return 0;
|
|
448
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// spexharness/src/chat/tui/palette.mjs
|
|
2
|
+
//
|
|
3
|
+
// GENERATED by scripts/gen-tui-palette.mjs — do not edit by hand.
|
|
4
|
+
// Source: spex-console/public/design-system/tokens.dtcg.json (dark theme).
|
|
5
|
+
// Regenerate: node scripts/gen-tui-palette.mjs
|
|
6
|
+
// Verify without writing: node scripts/gen-tui-palette.mjs --check
|
|
7
|
+
|
|
8
|
+
export const PALETTE = Object.freeze({
|
|
9
|
+
brand: '#00fa8e',
|
|
10
|
+
working: '#ff8a3d',
|
|
11
|
+
pending: '#f7c948',
|
|
12
|
+
blocked: '#ff5a44',
|
|
13
|
+
review: '#8b76e0',
|
|
14
|
+
approved: '#3b82f6',
|
|
15
|
+
secondary: '#b2b2b2',
|
|
16
|
+
stale: '#707070',
|
|
17
|
+
primary: '#ffffff',
|
|
18
|
+
highlight: '#3a3d42',
|
|
19
|
+
highlightSubtle: '#333333',
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
export const PALETTE_SOURCE = 'spex-console/public/design-system/tokens.dtcg.json';
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
// spexharness/src/chat/tui/select.mjs
|
|
2
|
+
//
|
|
3
|
+
// Arrow-key select prompt (HS-TJYK-8CMW-EPIC §Phase 2, task HS-3I1X, T15) — the numbered-list
|
|
4
|
+
// "walk with the arrows or a number and confirm with Enter" prompt every peer agent (Claude
|
|
5
|
+
// Code, Command Code, Cursor Agent) already uses for a folder-trust/tool-permission ask,
|
|
6
|
+
// replacing SpexCode's own typed y/n answer for those two asks. Parity source priority (owner
|
|
7
|
+
// ruling 2026-09-11): 1. Claude Code, 2. Command Code, 3. Cursor Agent — hence the `❯ n. label`
|
|
8
|
+
// list shape (Claude Code's own), not Cursor's bracketed `[a]` hotkey row.
|
|
9
|
+
//
|
|
10
|
+
// PURE PROMPT, NO DOCK — unlike input-box.mjs/status-bar.mjs, this module does not render
|
|
11
|
+
// through the shared dock (dock.mjs): it owns a small self-contained block (title + lines +
|
|
12
|
+
// numbered options + footer) and erases/rewrites THAT block itself, with its own
|
|
13
|
+
// cursorUp+clearScreenDown accounting (the same "erase the last K lines, rewrite them"
|
|
14
|
+
// technique dock.mjs uses, duplicated here rather than shared because select() needs its own
|
|
15
|
+
// synchronous Promise-driven keypress loop, not a `set()`/`erase()` pair a caller drives).
|
|
16
|
+
// Callers that ALSO use the shared dock (hs-chat.ts's folder-trust and tool-permission asks)
|
|
17
|
+
// are responsible for erasing the dock's own block before calling `select()` and redrawing it
|
|
18
|
+
// after — the same "erase what you own before something else draws in the same rows" contract
|
|
19
|
+
// input-box.mjs already follows for the menu/status-bar coordination; select() only ever
|
|
20
|
+
// touches the rows IT wrote.
|
|
21
|
+
//
|
|
22
|
+
// Raw-mode discipline mirrors keys.mjs's `bindKeys` exactly (same rule, deliberately
|
|
23
|
+
// duplicated rather than imported — `bindKeys` is wired to a `readline.Interface`'s own
|
|
24
|
+
// Shift+Tab/Escape hooks and has no notion of Up/Down/digit/key navigation or resolving a
|
|
25
|
+
// value, so reusing it here would mean threading a throwaway `rl` through just to satisfy its
|
|
26
|
+
// signature): raw mode is set only when `stdin.setRawMode` exists AND `stdin` was not already
|
|
27
|
+
// raw, and is only ever turned back OFF by THIS call if THIS call was the one that turned it
|
|
28
|
+
// on — never double-toggling a mode some other owner (an outer readline Interface) already
|
|
29
|
+
// holds.
|
|
30
|
+
//
|
|
31
|
+
// Non-TTY stdin: `select()` throws (rejects with) `SELECT_NEEDS_TTY` synchronously, before
|
|
32
|
+
// registering any listener or touching raw mode — the caller's contract (Scope 2/3: "TTY-only
|
|
33
|
+
// wiring") is to catch this and fall back to the existing typed y/n prompt, never to call
|
|
34
|
+
// `select()` speculatively and hope.
|
|
35
|
+
|
|
36
|
+
import readline from 'node:readline';
|
|
37
|
+
|
|
38
|
+
import { fg, reset, cursorUp, clearScreenDown, hideCursor, showCursor } from './ansi.mjs';
|
|
39
|
+
import { PALETTE } from './palette.mjs';
|
|
40
|
+
|
|
41
|
+
/** Thrown (as a rejected promise, `select` being `async`) when `stdin` is not a real TTY —
|
|
42
|
+
* the caller's signal to fall back to the typed prompt instead of hanging on a stream that
|
|
43
|
+
* will never deliver a raw keypress. Both `error.message` and `error.code` are set to this
|
|
44
|
+
* string so either check works. */
|
|
45
|
+
export const SELECT_NEEDS_TTY = 'SELECT_NEEDS_TTY';
|
|
46
|
+
|
|
47
|
+
/** Wrap `text` in `fg(hex, depth)` ... `reset` — at depth 0 `fg` returns '', so this degrades
|
|
48
|
+
* to plain text with no dangling reset (same convention as every other tui/*.mjs
|
|
49
|
+
* colorSegment helper — input-box.mjs, slash-menu.mjs, status-bar.mjs). */
|
|
50
|
+
function colorSegment(text, hex, depth) {
|
|
51
|
+
const prefix = fg(hex, depth);
|
|
52
|
+
return prefix ? `${prefix}${text}${reset}` : text;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Build this prompt's full block of lines (title, explanatory lines verbatim, the numbered
|
|
56
|
+
* option list with the selected row in brand green, and the footer hint) — pure, no I/O,
|
|
57
|
+
* called fresh on every render so `selectedIndex` is always reflected. `lines` are printed
|
|
58
|
+
* exactly as given (a caller that wants a colored path/sentence, e.g. the folder-trust ask's
|
|
59
|
+
* brand-colored path, pre-colors it itself — this module never re-colors caller text). */
|
|
60
|
+
function buildLines({ title, lines, options, selectedIndex, footer, depth }) {
|
|
61
|
+
const out = [];
|
|
62
|
+
if (title) out.push(colorSegment(String(title), PALETTE.primary, depth));
|
|
63
|
+
for (const line of Array.isArray(lines) ? lines : []) {
|
|
64
|
+
out.push(String(line ?? ''));
|
|
65
|
+
}
|
|
66
|
+
options.forEach((opt, i) => {
|
|
67
|
+
const n = i + 1;
|
|
68
|
+
const marker = i === selectedIndex ? '❯' : ' ';
|
|
69
|
+
const text = `${marker} ${n}. ${opt.label}`;
|
|
70
|
+
out.push(i === selectedIndex ? colorSegment(text, PALETTE.brand, depth) : text);
|
|
71
|
+
});
|
|
72
|
+
if (footer) out.push(colorSegment(String(footer), PALETTE.stale, depth));
|
|
73
|
+
return out;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** The index a single typed character jumps to, or -1 — a digit ("1".."9") jumps by 1-indexed
|
|
77
|
+
* position; a single-character `option.key` (case-insensitive) jumps by identity. Multi-
|
|
78
|
+
* character option keys (the common case — hs-chat.ts's callers use ids like "yes"/"exit",
|
|
79
|
+
* not single-letter hotkeys) simply never match here, which is fine: Up/Down and the digit
|
|
80
|
+
* jump always work regardless of what `key` looks like. */
|
|
81
|
+
function jumpIndexForChar(ch, options) {
|
|
82
|
+
if (typeof ch !== 'string' || ch.length !== 1) return -1;
|
|
83
|
+
if (/^[1-9]$/.test(ch)) {
|
|
84
|
+
const idx = Number(ch) - 1;
|
|
85
|
+
return idx < options.length ? idx : -1;
|
|
86
|
+
}
|
|
87
|
+
return options.findIndex(
|
|
88
|
+
(o) => typeof o?.key === 'string' && o.key.length === 1 && o.key.toLowerCase() === ch.toLowerCase(),
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* @param {object} opts
|
|
94
|
+
* @param {string} [opts.title]
|
|
95
|
+
* @param {string[]} [opts.lines] - explanatory lines, printed verbatim between the title and
|
|
96
|
+
* the option list.
|
|
97
|
+
* @param {Array<{ key: string, label: string, hint?: string }>} opts.options - non-empty.
|
|
98
|
+
* `hint`, when given, is appended to the option's row in the secondary color.
|
|
99
|
+
* @param {NodeJS.ReadStream} [opts.stdin] - defaults to `process.stdin`. Must be a real TTY
|
|
100
|
+
* (`isTTY === true`) or this rejects with `SELECT_NEEDS_TTY`.
|
|
101
|
+
* @param {{ write: (s: string) => unknown }} [opts.stdout] - defaults to `process.stdout`.
|
|
102
|
+
* @param {24 | 8 | 0} [opts.depth]
|
|
103
|
+
* @param {string} [opts.footer] - the navigation hint line (e.g. "↑/↓ to navigate · enter to
|
|
104
|
+
* select · esc to exit"), rendered in the secondary/stale color as the block's last line.
|
|
105
|
+
* @returns {Promise<string | null>} the chosen option's `key` on Enter, `null` on Escape.
|
|
106
|
+
*/
|
|
107
|
+
export async function select({
|
|
108
|
+
title,
|
|
109
|
+
lines = [],
|
|
110
|
+
options,
|
|
111
|
+
stdin = process.stdin,
|
|
112
|
+
stdout = process.stdout,
|
|
113
|
+
depth = 0,
|
|
114
|
+
footer,
|
|
115
|
+
} = {}) {
|
|
116
|
+
if (!Array.isArray(options) || options.length === 0) {
|
|
117
|
+
throw new Error('select(): options must be a non-empty array');
|
|
118
|
+
}
|
|
119
|
+
if (stdin.isTTY !== true) {
|
|
120
|
+
const err = new Error(SELECT_NEEDS_TTY);
|
|
121
|
+
err.code = SELECT_NEEDS_TTY;
|
|
122
|
+
throw err;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const alreadyRaw = stdin.isRaw === true;
|
|
126
|
+
const canSetRaw = typeof stdin.setRawMode === 'function';
|
|
127
|
+
|
|
128
|
+
readline.emitKeypressEvents(stdin);
|
|
129
|
+
|
|
130
|
+
let rawModeSetByUs = false;
|
|
131
|
+
if (canSetRaw && !alreadyRaw) {
|
|
132
|
+
stdin.setRawMode(true);
|
|
133
|
+
rawModeSetByUs = true;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
let selectedIndex = 0;
|
|
137
|
+
let height = 0;
|
|
138
|
+
|
|
139
|
+
stdout.write(hideCursor);
|
|
140
|
+
|
|
141
|
+
function render() {
|
|
142
|
+
const rendered = buildLines({ title, lines, options, selectedIndex, footer, depth });
|
|
143
|
+
let seq = '';
|
|
144
|
+
if (height > 0) seq += `${cursorUp(height)}\r${clearScreenDown}`;
|
|
145
|
+
for (const line of rendered) seq += `${line}\n`;
|
|
146
|
+
stdout.write(seq);
|
|
147
|
+
height = rendered.length;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function erase() {
|
|
151
|
+
if (height === 0) return;
|
|
152
|
+
stdout.write(`${cursorUp(height)}\r${clearScreenDown}`);
|
|
153
|
+
height = 0;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return new Promise((resolve) => {
|
|
157
|
+
function cleanup() {
|
|
158
|
+
stdin.removeListener('keypress', onKeypress);
|
|
159
|
+
if (rawModeSetByUs && canSetRaw) stdin.setRawMode(false);
|
|
160
|
+
stdout.write(showCursor);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function finish(result) {
|
|
164
|
+
erase();
|
|
165
|
+
cleanup();
|
|
166
|
+
resolve(result);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function onKeypress(str, key) {
|
|
170
|
+
if (!key) {
|
|
171
|
+
const jump = jumpIndexForChar(str, options);
|
|
172
|
+
if (jump !== -1 && jump !== selectedIndex) {
|
|
173
|
+
selectedIndex = jump;
|
|
174
|
+
render();
|
|
175
|
+
}
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
if (key.name === 'up') {
|
|
179
|
+
selectedIndex = (selectedIndex - 1 + options.length) % options.length;
|
|
180
|
+
render();
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
if (key.name === 'down') {
|
|
184
|
+
selectedIndex = (selectedIndex + 1) % options.length;
|
|
185
|
+
render();
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
if (key.name === 'return' || key.name === 'enter') {
|
|
189
|
+
finish(options[selectedIndex].key);
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
if (key.name === 'escape') {
|
|
193
|
+
finish(null);
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
const jump = jumpIndexForChar(str, options);
|
|
197
|
+
if (jump !== -1 && jump !== selectedIndex) {
|
|
198
|
+
selectedIndex = jump;
|
|
199
|
+
render();
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
stdin.on('keypress', onKeypress);
|
|
204
|
+
render();
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export default { select, SELECT_NEEDS_TTY };
|