@defrex/autobuild 0.8.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/LICENSE +201 -0
- package/README.md +192 -0
- package/SPEC.md +2269 -0
- package/bin/ab-build-runner.ts +47 -0
- package/bin/ab-dev.ts +27 -0
- package/bin/ab-dispatch-kernel.ts +92 -0
- package/bin/ab-harvest-runner.ts +29 -0
- package/bin/ab-init-probe.ts +19 -0
- package/bin/ab-process-group-reaper.ts +32 -0
- package/bin/ab.ts +6 -0
- package/bin/agent/ab +3 -0
- package/docs/answering-blocked-builds.md +89 -0
- package/docs/architecture.md +726 -0
- package/docs/assets/headline-wide.png +0 -0
- package/docs/configuration.md +1623 -0
- package/docs/hosted-dispatcher-cutover-validation.md +178 -0
- package/docs/hosted-dispatcher.md +204 -0
- package/docs/mcp.md +119 -0
- package/docs/operator-api.md +186 -0
- package/docs/remote-store-protocol.md +972 -0
- package/docs/remote-ticket-protocol.md +79 -0
- package/docs/setup.md +289 -0
- package/docs/spec-standard.md +63 -0
- package/docs/ticket-dependencies.md +59 -0
- package/docs/vercel-sandbox-rollout-probe.md +7 -0
- package/package.json +135 -0
- package/packages/core/src/cli/args.ts +74 -0
- package/packages/core/src/cli/artifact.ts +182 -0
- package/packages/core/src/cli/binary.ts +198 -0
- package/packages/core/src/cli/build-control.ts +789 -0
- package/packages/core/src/cli/build-progress.ts +55 -0
- package/packages/core/src/cli/bulk-control.ts +385 -0
- package/packages/core/src/cli/context.ts +470 -0
- package/packages/core/src/cli/dashboard/actions.ts +59 -0
- package/packages/core/src/cli/dashboard/cells.ts +92 -0
- package/packages/core/src/cli/dashboard/composer.ts +230 -0
- package/packages/core/src/cli/dashboard/detail.ts +100 -0
- package/packages/core/src/cli/dashboard/frame-image.ts +373 -0
- package/packages/core/src/cli/dashboard/live.ts +152 -0
- package/packages/core/src/cli/dashboard/model.ts +1173 -0
- package/packages/core/src/cli/dashboard/poll.ts +196 -0
- package/packages/core/src/cli/dashboard/render.ts +1568 -0
- package/packages/core/src/cli/dashboard/selection.ts +66 -0
- package/packages/core/src/cli/dashboard/session-feed.ts +244 -0
- package/packages/core/src/cli/dashboard/session-view.ts +506 -0
- package/packages/core/src/cli/dashboard/transcript.ts +108 -0
- package/packages/core/src/cli/dev.ts +46 -0
- package/packages/core/src/cli/dispatch-frontend.ts +1045 -0
- package/packages/core/src/cli/dispatch-process.ts +259 -0
- package/packages/core/src/cli/dispatch.ts +3692 -0
- package/packages/core/src/cli/dotenv.ts +48 -0
- package/packages/core/src/cli/env.ts +206 -0
- package/packages/core/src/cli/harvest.ts +698 -0
- package/packages/core/src/cli/help.ts +713 -0
- package/packages/core/src/cli/init-agent.ts +106 -0
- package/packages/core/src/cli/init-validation.ts +583 -0
- package/packages/core/src/cli/init.ts +756 -0
- package/packages/core/src/cli/installation.ts +333 -0
- package/packages/core/src/cli/keyboard.ts +261 -0
- package/packages/core/src/cli/main.ts +1399 -0
- package/packages/core/src/cli/mcp.ts +279 -0
- package/packages/core/src/cli/models.ts +65 -0
- package/packages/core/src/cli/observe.ts +55 -0
- package/packages/core/src/cli/plugin.ts +227 -0
- package/packages/core/src/cli/pr-attachments.ts +138 -0
- package/packages/core/src/cli/pr-summary.ts +124 -0
- package/packages/core/src/cli/publication-settlement.ts +188 -0
- package/packages/core/src/cli/repo-state.ts +213 -0
- package/packages/core/src/cli/repository-status.ts +80 -0
- package/packages/core/src/cli/self-update.ts +443 -0
- package/packages/core/src/cli/status.ts +895 -0
- package/packages/core/src/cli/store-opening.contract.ts +289 -0
- package/packages/core/src/cli/store-opening.ts +167 -0
- package/packages/core/src/cli/store-ref.ts +33 -0
- package/packages/core/src/cli/terminal-restore.ts +174 -0
- package/packages/core/src/cli/terminal.ts +361 -0
- package/packages/core/src/cli/terminals.ts +995 -0
- package/packages/core/src/cli/testkit.ts +194 -0
- package/packages/core/src/cli/ticket.ts +484 -0
- package/packages/core/src/cli/upgrade-agent.ts +180 -0
- package/packages/core/src/cli/upgrade-commit.ts +468 -0
- package/packages/core/src/cli/upgrade-notice.ts +91 -0
- package/packages/core/src/cli/upgrade-progress.ts +110 -0
- package/packages/core/src/cli/upgrade.ts +888 -0
- package/packages/core/src/cli/wait.ts +587 -0
- package/packages/core/src/cli/watch.ts +932 -0
- package/packages/core/src/config/live.ts +255 -0
- package/packages/core/src/config/load.ts +98 -0
- package/packages/core/src/config/roles.ts +438 -0
- package/packages/core/src/config/schema.ts +774 -0
- package/packages/core/src/distribution.ts +24 -0
- package/packages/core/src/events/catalog.ts +201 -0
- package/packages/core/src/events/envelope.ts +59 -0
- package/packages/core/src/events/payloads.ts +618 -0
- package/packages/core/src/events/repository.ts +559 -0
- package/packages/core/src/events/sessions.ts +180 -0
- package/packages/core/src/harvest/schema.ts +147 -0
- package/packages/core/src/ids.ts +45 -0
- package/packages/core/src/integration/harness.ts +681 -0
- package/packages/core/src/kernel/auto-merge.ts +259 -0
- package/packages/core/src/kernel/converge.ts +117 -0
- package/packages/core/src/kernel/dispatch-settings.ts +60 -0
- package/packages/core/src/kernel/dispatch-status.ts +141 -0
- package/packages/core/src/kernel/engine.ts +1047 -0
- package/packages/core/src/kernel/harvest.ts +823 -0
- package/packages/core/src/kernel/origin.ts +69 -0
- package/packages/core/src/kernel/phases.ts +196 -0
- package/packages/core/src/kernel/plan-verify-selection.ts +105 -0
- package/packages/core/src/kernel/pr-attachments.ts +82 -0
- package/packages/core/src/kernel/reducer.ts +752 -0
- package/packages/core/src/kernel/stall.ts +155 -0
- package/packages/core/src/kernel/verify-applicability.ts +52 -0
- package/packages/core/src/markdown.ts +67 -0
- package/packages/core/src/ontology.ts +288 -0
- package/packages/core/src/open-map.ts +203 -0
- package/packages/core/src/operator/annotations.ts +187 -0
- package/packages/core/src/operator/client.ts +311 -0
- package/packages/core/src/operator/control.ts +117 -0
- package/packages/core/src/operator/index.ts +62 -0
- package/packages/core/src/operator/presentation.ts +22 -0
- package/packages/core/src/operator/protocol.ts +98 -0
- package/packages/core/src/operator/query.ts +201 -0
- package/packages/core/src/operator/registry.ts +1043 -0
- package/packages/core/src/operator/requests.ts +138 -0
- package/packages/core/src/operator/sandbox.ts +458 -0
- package/packages/core/src/operator/server.ts +616 -0
- package/packages/core/src/operator/sessions.ts +223 -0
- package/packages/core/src/operator/tickets.ts +177 -0
- package/packages/core/src/plugin-sdk/index.ts +174 -0
- package/packages/core/src/plugins/contract-entry.ts +74 -0
- package/packages/core/src/plugins/contract-env.ts +3 -0
- package/packages/core/src/plugins/load.ts +259 -0
- package/packages/core/src/plugins/manifest.ts +236 -0
- package/packages/core/src/plugins/registry.ts +274 -0
- package/packages/core/src/plugins/runtimes.ts +75 -0
- package/packages/core/src/ports/forge/contract.ts +258 -0
- package/packages/core/src/ports/forge/create.ts +74 -0
- package/packages/core/src/ports/forge/fake.ts +488 -0
- package/packages/core/src/ports/forge/github-pr-attachments.ts +351 -0
- package/packages/core/src/ports/forge/github-transport.ts +352 -0
- package/packages/core/src/ports/forge/github.ts +1162 -0
- package/packages/core/src/ports/forge/local-git.ts +568 -0
- package/packages/core/src/ports/runner/claude.ts +873 -0
- package/packages/core/src/ports/runner/cli-stream.ts +90 -0
- package/packages/core/src/ports/runner/codex.ts +873 -0
- package/packages/core/src/ports/runner/contract.ts +492 -0
- package/packages/core/src/ports/runner/fake.ts +213 -0
- package/packages/core/src/ports/runner/json-record.ts +2 -0
- package/packages/core/src/ports/runner/live-contract-fixture.ts +176 -0
- package/packages/core/src/ports/runner/one-shot.ts +29 -0
- package/packages/core/src/ports/runner/pi-bridge-core.ts +136 -0
- package/packages/core/src/ports/runner/pi-bridge.js +6 -0
- package/packages/core/src/ports/runner/pi-rpc.ts +454 -0
- package/packages/core/src/ports/runner/pi.ts +617 -0
- package/packages/core/src/ports/runner/production.ts +74 -0
- package/packages/core/src/ports/runner/provider-error.ts +121 -0
- package/packages/core/src/ports/runner/routing.ts +234 -0
- package/packages/core/src/ports/runner/runtime.ts +214 -0
- package/packages/core/src/ports/runner/session-env.ts +34 -0
- package/packages/core/src/ports/runner/stream-parts.ts +103 -0
- package/packages/core/src/ports/tickets/contract.ts +570 -0
- package/packages/core/src/ports/tickets/create.ts +135 -0
- package/packages/core/src/ports/tickets/fake.ts +215 -0
- package/packages/core/src/ports/tickets/file.ts +559 -0
- package/packages/core/src/ports/tickets/linear.ts +811 -0
- package/packages/core/src/ports/tickets/operations.ts +161 -0
- package/packages/core/src/ports/tickets/remote-index.ts +13 -0
- package/packages/core/src/ports/tickets/remote-protocol.ts +80 -0
- package/packages/core/src/ports/tickets/remote-server.ts +185 -0
- package/packages/core/src/ports/tickets/remote.ts +130 -0
- package/packages/core/src/ports/tickets/update.ts +51 -0
- package/packages/core/src/ports/types.ts +528 -0
- package/packages/core/src/ports/workspace/build-execution.ts +123 -0
- package/packages/core/src/ports/workspace/contract.ts +136 -0
- package/packages/core/src/ports/workspace/create.ts +132 -0
- package/packages/core/src/ports/workspace/distribution-archive.ts +317 -0
- package/packages/core/src/ports/workspace/fake.ts +432 -0
- package/packages/core/src/ports/workspace/git-worktree.ts +848 -0
- package/packages/core/src/ports/workspace/harvest-execution.ts +111 -0
- package/packages/core/src/ports/workspace/in-process-build-execution.ts +30 -0
- package/packages/core/src/ports/workspace/local-build-execution.ts +88 -0
- package/packages/core/src/ports/workspace/operator-sandbox.ts +142 -0
- package/packages/core/src/ports/workspace/phase-scratch.ts +156 -0
- package/packages/core/src/ports/workspace/vercel-sandbox.ts +2541 -0
- package/packages/core/src/processes/build-child-exit.ts +78 -0
- package/packages/core/src/processes/build-child.ts +119 -0
- package/packages/core/src/processes/build-execution-state.ts +95 -0
- package/packages/core/src/processes/build-parent-watch.ts +35 -0
- package/packages/core/src/processes/build-runner.ts +2614 -0
- package/packages/core/src/processes/dispatcher.ts +2825 -0
- package/packages/core/src/processes/execution-settlement.ts +171 -0
- package/packages/core/src/processes/harvest-child.ts +115 -0
- package/packages/core/src/processes/harvest-execution-state.ts +121 -0
- package/packages/core/src/processes/harvest-runner.ts +1656 -0
- package/packages/core/src/processes/harvest.ts +595 -0
- package/packages/core/src/processes/infrastructure-failure-budget.ts +146 -0
- package/packages/core/src/processes/phase-failure-budget.ts +52 -0
- package/packages/core/src/processes/process-group-reaper.ts +35 -0
- package/packages/core/src/processes/process-group.ts +65 -0
- package/packages/core/src/processes/publication-loss.ts +62 -0
- package/packages/core/src/processes/publication-state.ts +93 -0
- package/packages/core/src/processes/sandbox-state.ts +101 -0
- package/packages/core/src/registry.ts +24 -0
- package/packages/core/src/sessions/reducer.ts +185 -0
- package/packages/core/src/skills.ts +7 -0
- package/packages/core/src/spec-standard.ts +47 -0
- package/packages/core/src/store/adapter.ts +80 -0
- package/packages/core/src/store/build-scope.ts +272 -0
- package/packages/core/src/store/contract.ts +2581 -0
- package/packages/core/src/store/local/blobs.ts +38 -0
- package/packages/core/src/store/local/schema.ts +180 -0
- package/packages/core/src/store/local/store.ts +1713 -0
- package/packages/core/src/store/memory.ts +1138 -0
- package/packages/core/src/store/phase-session.ts +425 -0
- package/packages/core/src/store/remote/client.ts +780 -0
- package/packages/core/src/store/remote/index.ts +12 -0
- package/packages/core/src/store/remote/protocol.ts +348 -0
- package/packages/core/src/store/remote/server.ts +923 -0
- package/packages/core/src/store/remote/token.ts +137 -0
- package/packages/core/src/store/remote/version.ts +10 -0
- package/packages/core/src/store/retention.ts +91 -0
- package/packages/core/src/store/session-handle.ts +265 -0
- package/packages/core/src/store/streams/assemble.ts +79 -0
- package/packages/core/src/store/streams/lock.ts +42 -0
- package/packages/core/src/store/streams/session-writer.ts +306 -0
- package/packages/core/src/store/streams/types.ts +171 -0
- package/packages/core/src/store/streams/wait.ts +127 -0
- package/packages/core/src/store/subscribe.ts +105 -0
- package/packages/core/src/store/types.ts +466 -0
- package/packages/core/src/testing/fixed.ts +44 -0
- package/packages/core/src/testing/store-failures.ts +32 -0
- package/packages/core/src/zod-issues.ts +91 -0
- package/skills/code-review/SKILL.md +100 -0
- package/skills/finalize/SKILL.md +44 -0
- package/skills/guide/SKILL.md +1781 -0
- package/skills/guide/references/operator-api.md +164 -0
- package/skills/guide/references/plugin-authoring.md +354 -0
- package/skills/guide/references/remote-store-protocol.md +972 -0
- package/skills/guide/references/remote-ticket-protocol.md +79 -0
- package/skills/guide/references/setup.md +289 -0
- package/skills/guide/references/spec-standard.md +63 -0
- package/skills/guide/references/ticket-dependencies.md +59 -0
- package/skills/harvest/SKILL.md +66 -0
- package/skills/harvest-review/SKILL.md +63 -0
- package/skills/implement/SKILL.md +82 -0
- package/skills/plan/SKILL.md +123 -0
- package/skills/plan-review/SKILL.md +121 -0
- package/skills/reconcile/SKILL.md +65 -0
- package/skills/spec/SKILL.md +132 -0
- package/skills/tickets/SKILL.md +139 -0
- package/templates/autobuild.toml +36 -0
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
import { writeSync } from 'node:fs'
|
|
2
|
+
import { emitKeypressEvents } from 'node:readline'
|
|
3
|
+
import {
|
|
4
|
+
decodeCsi,
|
|
5
|
+
endsCsi,
|
|
6
|
+
INTERRUPT_WATCH_MAX,
|
|
7
|
+
isCsiParameterByte,
|
|
8
|
+
isInterruptSequence,
|
|
9
|
+
MAX_CSI_PARAM_DIGITS,
|
|
10
|
+
type CsiReport,
|
|
11
|
+
} from './keyboard'
|
|
12
|
+
import { createTerminalModeController, type TerminalModeController } from './terminal-restore'
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The output seam for interactive rendering (SPEC §14).
|
|
16
|
+
*
|
|
17
|
+
* `ab dispatch` writes lines through a `stdout: (line) => void` dep. That is
|
|
18
|
+
* the right shape for append-only output and the wrong one for a redrawn
|
|
19
|
+
* region, which needs raw writes (no implicit newline), the terminal width,
|
|
20
|
+
* and — above all — an honest answer to "is anyone watching?".
|
|
21
|
+
*
|
|
22
|
+
* The seam is deliberately tiny and injectable: `DispatchOpts.terminal` is
|
|
23
|
+
* OPTIONAL, and absent ⇒ non-interactive ⇒ plain output. That default is what
|
|
24
|
+
* keeps every existing dispatch test (and every piped invocation) on exactly
|
|
25
|
+
* today's behavior, and it means the dashboard can never be the reason a
|
|
26
|
+
* scripted `ab dispatch` starts emitting escape sequences.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
export type TerminalInputEvent =
|
|
30
|
+
| { type: 'up' }
|
|
31
|
+
| { type: 'down' }
|
|
32
|
+
| { type: 'left' }
|
|
33
|
+
| { type: 'right' }
|
|
34
|
+
| { type: 'home' }
|
|
35
|
+
| { type: 'end' }
|
|
36
|
+
| { type: 'enter' }
|
|
37
|
+
| { type: 'newline' }
|
|
38
|
+
| { type: 'backspace' }
|
|
39
|
+
| { type: 'escape' }
|
|
40
|
+
| { type: 'interrupt' }
|
|
41
|
+
| { type: 'text'; text: string }
|
|
42
|
+
/** One bracketed paste, delivered whole. Never split, never capped: the
|
|
43
|
+
* buffer has to hold what the operator actually pasted. */
|
|
44
|
+
| { type: 'paste'; text: string }
|
|
45
|
+
|
|
46
|
+
export interface TerminalInputHooks {
|
|
47
|
+
onListening?(): void
|
|
48
|
+
onKeyboardFlags?(flags: number): void
|
|
49
|
+
onDeviceAttributes?(): void
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Injectable keyboard seam. Starting returns an idempotent cleanup. */
|
|
53
|
+
export interface TerminalInput {
|
|
54
|
+
start(onInput: (input: TerminalInputEvent) => void, hooks?: TerminalInputHooks): () => void
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface TerminalOut {
|
|
58
|
+
/** Raw write — no newline appended (unlike the line-oriented stdout dep). */
|
|
59
|
+
write(chunk: string): void
|
|
60
|
+
/** Shared ledger for ordinary teardown and synchronous process restoration. */
|
|
61
|
+
modes: TerminalModeController
|
|
62
|
+
/** Terminal width in columns; a sane fallback when unknown. */
|
|
63
|
+
columns: number
|
|
64
|
+
/** Terminal height in rows; a sane fallback when unknown. The live region
|
|
65
|
+
* snapshots this on every update so a resize invalidates its top-anchored
|
|
66
|
+
* alternate-screen paint. The frame still has to leave one spare row for its
|
|
67
|
+
* trailing newline. */
|
|
68
|
+
rows: number
|
|
69
|
+
/** True only for a real TTY — false for pipes and redirects. */
|
|
70
|
+
interactive: boolean
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Dimensions to assume when the stream reports none — the conventional 80x24. */
|
|
74
|
+
const FALLBACK_COLUMNS = 80
|
|
75
|
+
const FALLBACK_ROWS = 24
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* A terminal dimension, or the fallback.
|
|
79
|
+
*
|
|
80
|
+
* The guard is `> 0`, not `?? `: a TTY may report **0** — `script(1)`, many pty
|
|
81
|
+
* wrappers, and some CI runners all do — and `0 ?? 80` is `0`. Zero is not a
|
|
82
|
+
* dimension; it means "this terminal will not say". (A zero width truncated
|
|
83
|
+
* every line to nothing and collapsed the dashboard into a column of
|
|
84
|
+
* ellipses; a zero height would clamp the whole build list away.)
|
|
85
|
+
*/
|
|
86
|
+
function dimension(value: number | undefined, fallback: number): number {
|
|
87
|
+
return typeof value === 'number' && value > 0 ? value : fallback
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* The real terminal over a Node/Bun write stream.
|
|
92
|
+
*
|
|
93
|
+
* `isTTY` is `undefined` when stdout is a pipe or a file, so
|
|
94
|
+
* `stream.isTTY === true` delivers "non-interactive output, including
|
|
95
|
+
* redirected or piped output, automatically uses plain mode" for free — there
|
|
96
|
+
* is no separate detection path to keep in sync.
|
|
97
|
+
*
|
|
98
|
+
* `columns` and `rows` are getters, not snapshots: a resized window is picked
|
|
99
|
+
* up on the next frame without anyone subscribing to SIGWINCH.
|
|
100
|
+
*/
|
|
101
|
+
export function processTerminal(
|
|
102
|
+
stream: NodeJS.WriteStream = process.stdout,
|
|
103
|
+
emergencyWrite: (chunk: string) => void = (chunk) => {
|
|
104
|
+
const fd = (stream as unknown as { fd?: number }).fd
|
|
105
|
+
if (fd === undefined) throw new Error('terminal stream has no synchronous file descriptor')
|
|
106
|
+
writeSync(fd, chunk)
|
|
107
|
+
},
|
|
108
|
+
): TerminalOut {
|
|
109
|
+
const write = (chunk: string): void => {
|
|
110
|
+
stream.write(chunk)
|
|
111
|
+
}
|
|
112
|
+
return {
|
|
113
|
+
write,
|
|
114
|
+
modes: createTerminalModeController(write, emergencyWrite),
|
|
115
|
+
get columns(): number {
|
|
116
|
+
return dimension(stream.columns, FALLBACK_COLUMNS)
|
|
117
|
+
},
|
|
118
|
+
get rows(): number {
|
|
119
|
+
return dimension(stream.rows, FALLBACK_ROWS)
|
|
120
|
+
},
|
|
121
|
+
interactive: stream.isTTY === true,
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
interface Keypress {
|
|
126
|
+
name?: string
|
|
127
|
+
ctrl?: boolean
|
|
128
|
+
meta?: boolean
|
|
129
|
+
sequence?: string
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** A keyboard decoder holding the small amount of state a composer needs:
|
|
133
|
+
* bracketed-paste accumulation and CSI reassembly. One per `start()`. */
|
|
134
|
+
export interface TerminalInputDecoder {
|
|
135
|
+
press(text: string | undefined, key: Keypress): TerminalInputEvent | undefined
|
|
136
|
+
/** Drop any partial paste, CSI run, or interrupt watch when the region tears down. */
|
|
137
|
+
cleanup(): void
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Normalize readline's platform-dependent keypress shape without deciding what
|
|
142
|
+
* printable characters mean. In particular, `m`, `p`, and `d` remain text
|
|
143
|
+
* here: the dispatch controller maps them to commands only while no text input
|
|
144
|
+
* is active, so they can be typed into blocked-build feedback.
|
|
145
|
+
*
|
|
146
|
+
* **CR submits, LF inserts a newline.** They arrive distinctly (`'return'`
|
|
147
|
+
* versus `'enter'`), and Ctrl-J is literally LF - a newline binding that needs
|
|
148
|
+
* no protocol negotiation and works in every terminal that runs the dashboard
|
|
149
|
+
* today. Outside the resume prompt the controller treats `newline` exactly
|
|
150
|
+
* like `enter`, so only the composer can tell the two apart.
|
|
151
|
+
*
|
|
152
|
+
* Two facts about Node/Bun's `emitKeypressEvents`, both probed rather than
|
|
153
|
+
* recalled, shape the state this holds:
|
|
154
|
+
*
|
|
155
|
+
* - `key.name` for an unrecognized CSI is the literal STRING `'undefined'`.
|
|
156
|
+
* - The parser gives up at the first `:` in a parameter list and during longer
|
|
157
|
+
* numeric parameters, emitting the partial sequence plus the remaining bytes
|
|
158
|
+
* as ordinary printable text. Lock-bearing arrows, associated text, and
|
|
159
|
+
* alternate-layout chords therefore all require reassembly.
|
|
160
|
+
*
|
|
161
|
+
* So a partial CSI opens a reassembly buffer and later fragments are appended
|
|
162
|
+
* rather than delivered, which is what stops a Kitty Shift+Enter from
|
|
163
|
+
* injecting `1u` into the operator's guidance.
|
|
164
|
+
*/
|
|
165
|
+
export function createTerminalInputDecoder(hooks: TerminalInputHooks = {}): TerminalInputDecoder {
|
|
166
|
+
type CsiState = { buffer: string; digitRun: number; draining: boolean }
|
|
167
|
+
let csi: CsiState | undefined
|
|
168
|
+
let paste: string | undefined
|
|
169
|
+
let watch: string | undefined
|
|
170
|
+
|
|
171
|
+
const reportEvent = (report: CsiReport): TerminalInputEvent | undefined => {
|
|
172
|
+
if (report.kind === 'input') return report.event
|
|
173
|
+
if (report.kind === 'keyboard-flags') hooks.onKeyboardFlags?.(report.flags)
|
|
174
|
+
if (report.kind === 'device-attributes') hooks.onDeviceAttributes?.()
|
|
175
|
+
return undefined
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Consume one parser fragment. `false` lets a malformed fragment reach the keymap. */
|
|
179
|
+
const consumeCsi = (
|
|
180
|
+
fragment: string,
|
|
181
|
+
restart: boolean,
|
|
182
|
+
): { consumed: boolean; event?: TerminalInputEvent } => {
|
|
183
|
+
if (restart) csi = { buffer: '\x1b[', digitRun: 0, draining: false }
|
|
184
|
+
if (csi === undefined) return { consumed: false }
|
|
185
|
+
const payload = restart ? fragment.slice(2) : fragment
|
|
186
|
+
|
|
187
|
+
for (let index = 0; index < payload.length; index += 1) {
|
|
188
|
+
const ch = payload[index] ?? ''
|
|
189
|
+
if (isCsiParameterByte(ch)) {
|
|
190
|
+
if (ch >= '0' && ch <= '9') csi.digitRun += 1
|
|
191
|
+
else csi.digitRun = 0
|
|
192
|
+
if (csi.digitRun > MAX_CSI_PARAM_DIGITS) {
|
|
193
|
+
csi.draining = true
|
|
194
|
+
csi.buffer = ''
|
|
195
|
+
} else if (!csi.draining) {
|
|
196
|
+
csi.buffer += ch
|
|
197
|
+
}
|
|
198
|
+
continue
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const code = ch.charCodeAt(0)
|
|
202
|
+
const final = code >= 0x40 && code <= 0x7e
|
|
203
|
+
if (final && index === payload.length - 1) {
|
|
204
|
+
const completed = csi.draining ? undefined : csi.buffer + ch
|
|
205
|
+
csi = undefined
|
|
206
|
+
return {
|
|
207
|
+
consumed: true,
|
|
208
|
+
...(completed === undefined ? {} : { event: reportEvent(decodeCsi(completed)) }),
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
csi = undefined
|
|
213
|
+
return { consumed: false }
|
|
214
|
+
}
|
|
215
|
+
return { consumed: true }
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
return {
|
|
219
|
+
cleanup(): void {
|
|
220
|
+
csi = undefined
|
|
221
|
+
paste = undefined
|
|
222
|
+
watch = undefined
|
|
223
|
+
},
|
|
224
|
+
press(text: string | undefined, key: Keypress): TerminalInputEvent | undefined {
|
|
225
|
+
const fragment = key.sequence ?? text ?? ''
|
|
226
|
+
// Interrupt FIRST in every state. The parallel watch never consumes a
|
|
227
|
+
// byte; it only adds an interrupt, including inside bracketed paste. A
|
|
228
|
+
// pasted literal CSI-u Ctrl-C therefore interrupts, matching the existing
|
|
229
|
+
// and deliberate behavior of a literal ETX in a paste.
|
|
230
|
+
if ((key.ctrl === true && key.name === 'c') || key.sequence === '\u0003') {
|
|
231
|
+
csi = undefined
|
|
232
|
+
paste = undefined
|
|
233
|
+
watch = undefined
|
|
234
|
+
return { type: 'interrupt' }
|
|
235
|
+
}
|
|
236
|
+
if (fragment.startsWith('\x1b[')) watch = fragment
|
|
237
|
+
else if (watch !== undefined) watch += fragment
|
|
238
|
+
if (watch !== undefined && watch.length > INTERRUPT_WATCH_MAX) watch = undefined
|
|
239
|
+
if (watch !== undefined && endsCsi(watch)) {
|
|
240
|
+
const completed = watch
|
|
241
|
+
watch = undefined
|
|
242
|
+
if (isInterruptSequence(completed)) {
|
|
243
|
+
csi = undefined
|
|
244
|
+
paste = undefined
|
|
245
|
+
return { type: 'interrupt' }
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Inside a bracketed paste every byte is content, so this precedes both
|
|
250
|
+
// reassembly and the keymap. Line endings normalize to `\n` and other
|
|
251
|
+
// control characters are dropped, but the text is never shortened.
|
|
252
|
+
if (paste !== undefined) {
|
|
253
|
+
if (key.name === 'paste-end') {
|
|
254
|
+
const pasted = paste
|
|
255
|
+
.replace(/\r\n?/gu, '\n')
|
|
256
|
+
.replace(/[\u0000-\u0009\u000b-\u001f\u007f]/gu, '')
|
|
257
|
+
paste = undefined
|
|
258
|
+
return { type: 'paste', text: pasted }
|
|
259
|
+
}
|
|
260
|
+
paste += fragment
|
|
261
|
+
return undefined
|
|
262
|
+
}
|
|
263
|
+
if (key.name === 'paste-start') {
|
|
264
|
+
csi = undefined
|
|
265
|
+
paste = ''
|
|
266
|
+
return undefined
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const restart = fragment.startsWith('\x1b[')
|
|
270
|
+
if (csi !== undefined || (key.name === 'undefined' && restart)) {
|
|
271
|
+
const consumed = consumeCsi(fragment, restart)
|
|
272
|
+
if (consumed.consumed) return consumed.event
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
if (key.name === 'up') return { type: 'up' }
|
|
276
|
+
if (key.name === 'down') return { type: 'down' }
|
|
277
|
+
if (key.name === 'left') return { type: 'left' }
|
|
278
|
+
if (key.name === 'right') return { type: 'right' }
|
|
279
|
+
if (key.name === 'home') return { type: 'home' }
|
|
280
|
+
if (key.name === 'end') return { type: 'end' }
|
|
281
|
+
if (key.name === 'return' || key.sequence === '\r') {
|
|
282
|
+
return key.meta === true ? { type: 'newline' } : { type: 'enter' }
|
|
283
|
+
}
|
|
284
|
+
if (key.name === 'enter' || key.sequence === '\n') return { type: 'newline' }
|
|
285
|
+
if (key.name === 'backspace' || key.sequence === '\b' || key.sequence === '\u007f') {
|
|
286
|
+
return { type: 'backspace' }
|
|
287
|
+
}
|
|
288
|
+
if (key.name === 'escape' || key.sequence === '\u001b') return { type: 'escape' }
|
|
289
|
+
|
|
290
|
+
if (key.ctrl === true || key.meta === true) return undefined
|
|
291
|
+
const printable =
|
|
292
|
+
text ??
|
|
293
|
+
(key.name?.length === 1 ? key.name : key.sequence !== undefined ? key.sequence : undefined)
|
|
294
|
+
if (
|
|
295
|
+
printable === undefined ||
|
|
296
|
+
printable.length === 0 ||
|
|
297
|
+
/[\u0000-\u001f\u007f]/u.test(printable)
|
|
298
|
+
) {
|
|
299
|
+
return undefined
|
|
300
|
+
}
|
|
301
|
+
return { type: 'text', text: printable }
|
|
302
|
+
},
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Production raw-input adapter. It activates only for a TTY with raw-mode
|
|
308
|
+
* support, disables terminal echo through raw mode, and restores the stream's
|
|
309
|
+
* prior raw/flow state on every idempotent cleanup path.
|
|
310
|
+
*/
|
|
311
|
+
export function processTerminalInput(stream: NodeJS.ReadStream = process.stdin): TerminalInput {
|
|
312
|
+
return {
|
|
313
|
+
start(onKey, hooks = {}): () => void {
|
|
314
|
+
if (stream.isTTY !== true || typeof stream.setRawMode !== 'function') {
|
|
315
|
+
return () => {}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const priorRaw = stream.isRaw === true
|
|
319
|
+
const priorFlowing = stream.readableFlowing
|
|
320
|
+
let cleaned = false
|
|
321
|
+
// One decoder per activation: its paste and CSI state belongs to this
|
|
322
|
+
// region's lifetime, and an unterminated paste dies with it.
|
|
323
|
+
const decoder = createTerminalInputDecoder(hooks)
|
|
324
|
+
const listener = (text: string | undefined, key: Keypress = {}): void => {
|
|
325
|
+
const normalized = decoder.press(text, key)
|
|
326
|
+
if (normalized !== undefined) onKey(normalized)
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
let listening = false
|
|
330
|
+
const cleanup = (): void => {
|
|
331
|
+
if (cleaned) return
|
|
332
|
+
cleaned = true
|
|
333
|
+
decoder.cleanup()
|
|
334
|
+
if (listening) stream.removeListener('keypress', listener)
|
|
335
|
+
try {
|
|
336
|
+
stream.setRawMode(priorRaw)
|
|
337
|
+
} finally {
|
|
338
|
+
if (priorFlowing === true) stream.resume()
|
|
339
|
+
else stream.pause()
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
try {
|
|
344
|
+
emitKeypressEvents(stream)
|
|
345
|
+
stream.on('keypress', listener)
|
|
346
|
+
listening = true
|
|
347
|
+
stream.setRawMode(true)
|
|
348
|
+
stream.resume()
|
|
349
|
+
hooks.onListening?.()
|
|
350
|
+
return cleanup
|
|
351
|
+
} catch (error) {
|
|
352
|
+
try {
|
|
353
|
+
cleanup()
|
|
354
|
+
} catch {
|
|
355
|
+
// Preserve the activation error; cleanup was best-effort here.
|
|
356
|
+
}
|
|
357
|
+
throw error
|
|
358
|
+
}
|
|
359
|
+
},
|
|
360
|
+
}
|
|
361
|
+
}
|