@letta-ai/letta-code 0.1.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 +190 -0
- package/README.md +122 -0
- package/bin/letta +0 -0
- package/package.json +54 -0
- package/scripts/postinstall-patches.js +106 -0
- package/vendor/ink/build/components/App.js +348 -0
- package/vendor/ink/build/devtools.js +6 -0
- package/vendor/ink/build/hooks/use-input.js +101 -0
- package/vendor/ink-text-input/build/index.js +115 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
Apache License
|
|
2
|
+
Version 2.0, January 2004
|
|
3
|
+
http://www.apache.org/licenses/
|
|
4
|
+
|
|
5
|
+
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
|
6
|
+
|
|
7
|
+
1. Definitions.
|
|
8
|
+
|
|
9
|
+
"License" shall mean the terms and conditions for use, reproduction,
|
|
10
|
+
and distribution as defined by Sections 1 through 9 of this document.
|
|
11
|
+
|
|
12
|
+
"Licensor" shall mean the copyright owner or entity authorized by
|
|
13
|
+
the copyright owner that is granting the License.
|
|
14
|
+
|
|
15
|
+
"Legal Entity" shall mean the union of the acting entity and all
|
|
16
|
+
other entities that control, are controlled by, or are under common
|
|
17
|
+
control with that entity. For the purposes of this definition,
|
|
18
|
+
"control" means (i) the power, direct or indirect, to cause the
|
|
19
|
+
direction or management of such entity, whether by contract or
|
|
20
|
+
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
|
21
|
+
outstanding shares, or (iii) beneficial ownership of such entity.
|
|
22
|
+
|
|
23
|
+
"You" (or "Your") shall mean an individual or Legal Entity
|
|
24
|
+
exercising permissions granted by this License.
|
|
25
|
+
|
|
26
|
+
"Source" form shall mean the preferred form for making modifications,
|
|
27
|
+
including but not limited to software source code, documentation
|
|
28
|
+
source, and configuration files.
|
|
29
|
+
|
|
30
|
+
"Object" form shall mean any form resulting from mechanical
|
|
31
|
+
transformation or translation of a Source form, including but
|
|
32
|
+
not limited to compiled object code, generated documentation,
|
|
33
|
+
and conversions to other media types.
|
|
34
|
+
|
|
35
|
+
"Work" shall mean the work of authorship, whether in Source or
|
|
36
|
+
Object form, made available under the License, as indicated by a
|
|
37
|
+
copyright notice that is included in or attached to the work
|
|
38
|
+
(an example is provided in the Appendix below).
|
|
39
|
+
|
|
40
|
+
"Derivative Works" shall mean any work, whether in Source or Object
|
|
41
|
+
form, that is based on (or derived from) the Work and for which the
|
|
42
|
+
editorial revisions, annotations, elaborations, or other modifications
|
|
43
|
+
represent, as a whole, an original work of authorship. For the purposes
|
|
44
|
+
of this License, Derivative Works shall not include works that remain
|
|
45
|
+
separable from, or merely link (or bind by name) to the interfaces of,
|
|
46
|
+
the Work and Derivative Works thereof.
|
|
47
|
+
|
|
48
|
+
"Contribution" shall mean any work of authorship, including
|
|
49
|
+
the original version of the Work and any modifications or additions
|
|
50
|
+
to that Work or Derivative Works thereof, that is intentionally
|
|
51
|
+
submitted to Licensor for inclusion in the Work by the copyright owner
|
|
52
|
+
or by an individual or Legal Entity authorized to submit on behalf of
|
|
53
|
+
the copyright owner. For the purposes of this definition, "submitted"
|
|
54
|
+
means any form of electronic, verbal, or written communication sent
|
|
55
|
+
to the Licensor or its representatives, including but not limited to
|
|
56
|
+
communication on electronic mailing lists, source code control systems,
|
|
57
|
+
and issue tracking systems that are managed by, or on behalf of, the
|
|
58
|
+
Licensor for the purpose of discussing and improving the Work, but
|
|
59
|
+
excluding communication that is conspicuously marked or otherwise
|
|
60
|
+
designated in writing by the copyright owner as "Not a Contribution."
|
|
61
|
+
|
|
62
|
+
"Contributor" shall mean Licensor and any individual or Legal Entity
|
|
63
|
+
on behalf of whom a Contribution has been received by Licensor and
|
|
64
|
+
subsequently incorporated within the Work.
|
|
65
|
+
|
|
66
|
+
2. Grant of Copyright License. Subject to the terms and conditions of
|
|
67
|
+
this License, each Contributor hereby grants to You a perpetual,
|
|
68
|
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
69
|
+
copyright license to reproduce, prepare Derivative Works of,
|
|
70
|
+
publicly display, publicly perform, sublicense, and distribute the
|
|
71
|
+
Work and such Derivative Works in Source or Object form.
|
|
72
|
+
|
|
73
|
+
3. Grant of Patent License. Subject to the terms and conditions of
|
|
74
|
+
this License, each Contributor hereby grants to You a perpetual,
|
|
75
|
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
76
|
+
(except as stated in this section) patent license to make, have made,
|
|
77
|
+
use, offer to sell, sell, import, and otherwise transfer the Work,
|
|
78
|
+
where such license applies only to those patent claims licensable
|
|
79
|
+
by such Contributor that are necessarily infringed by their
|
|
80
|
+
Contribution(s) alone or by combination of their Contribution(s)
|
|
81
|
+
with the Work to which such Contribution(s) was submitted. If You
|
|
82
|
+
institute patent litigation against any entity (including a
|
|
83
|
+
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
|
84
|
+
or a Contribution incorporated within the Work constitutes direct
|
|
85
|
+
or contributory patent infringement, then any patent licenses
|
|
86
|
+
granted to You under this License for that Work shall terminate
|
|
87
|
+
as of the date such litigation is filed.
|
|
88
|
+
|
|
89
|
+
4. Redistribution. You may reproduce and distribute copies of the
|
|
90
|
+
Work or Derivative Works thereof in any medium, with or without
|
|
91
|
+
modifications, and in Source or Object form, provided that You
|
|
92
|
+
meet the following conditions:
|
|
93
|
+
|
|
94
|
+
(a) You must give any other recipients of the Work or
|
|
95
|
+
Derivative Works a copy of this License; and
|
|
96
|
+
|
|
97
|
+
(b) You must cause any modified files to carry prominent notices
|
|
98
|
+
stating that You changed the files; and
|
|
99
|
+
|
|
100
|
+
(c) You must retain, in the Source form of any Derivative Works
|
|
101
|
+
that You distribute, all copyright, patent, trademark, and
|
|
102
|
+
attribution notices from the Source form of the Work,
|
|
103
|
+
excluding those notices that do not pertain to any part of
|
|
104
|
+
the Derivative Works; and
|
|
105
|
+
|
|
106
|
+
(d) If the Work includes a "NOTICE" text file as part of its
|
|
107
|
+
distribution, then any Derivative Works that You distribute must
|
|
108
|
+
include a readable copy of the attribution notices contained
|
|
109
|
+
within such NOTICE file, excluding those notices that do not
|
|
110
|
+
pertain to any part of the Derivative Works, in at least one
|
|
111
|
+
of the following places: within a NOTICE text file distributed
|
|
112
|
+
as part of the Derivative Works; within the Source form or
|
|
113
|
+
documentation, if provided along with the Derivative Works; or,
|
|
114
|
+
within a display generated by the Derivative Works, if and
|
|
115
|
+
wherever such third-party notices normally appear. The contents
|
|
116
|
+
of the NOTICE file are for informational purposes only and
|
|
117
|
+
do not modify the License. You may add Your own attribution
|
|
118
|
+
notices within Derivative Works that You distribute, alongside
|
|
119
|
+
or as an addendum to the NOTICE text from the Work, provided
|
|
120
|
+
that such additional attribution notices cannot be construed
|
|
121
|
+
as modifying the License.
|
|
122
|
+
|
|
123
|
+
You may add Your own copyright statement to Your modifications and
|
|
124
|
+
may provide additional or different license terms and conditions
|
|
125
|
+
for use, reproduction, or distribution of Your modifications, or
|
|
126
|
+
for any such Derivative Works as a whole, provided Your use,
|
|
127
|
+
reproduction, and distribution of the Work otherwise complies with
|
|
128
|
+
the conditions stated in this License.
|
|
129
|
+
|
|
130
|
+
5. Submission of Contributions. Unless You explicitly state otherwise,
|
|
131
|
+
any Contribution intentionally submitted for inclusion in the Work
|
|
132
|
+
by You to the Licensor shall be under the terms and conditions of
|
|
133
|
+
this License, without any additional terms or conditions.
|
|
134
|
+
Notwithstanding the above, nothing herein shall supersede or modify
|
|
135
|
+
the terms of any separate license agreement you may have executed
|
|
136
|
+
with Licensor regarding such Contributions.
|
|
137
|
+
|
|
138
|
+
6. Trademarks. This License does not grant permission to use the trade
|
|
139
|
+
names, trademarks, service marks, or product names of the Licensor,
|
|
140
|
+
except as required for reasonable and customary use in describing the
|
|
141
|
+
origin of the Work and reproducing the content of the NOTICE file.
|
|
142
|
+
|
|
143
|
+
7. Disclaimer of Warranty. Unless required by applicable law or
|
|
144
|
+
agreed to in writing, Licensor provides the Work (and each
|
|
145
|
+
Contributor provides its Contributions) on an "AS IS" BASIS,
|
|
146
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
|
147
|
+
implied, including, without limitation, any warranties or conditions
|
|
148
|
+
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
|
149
|
+
PARTICULAR PURPOSE. You are solely responsible for determining the
|
|
150
|
+
appropriateness of using or redistributing the Work and assume any
|
|
151
|
+
risks associated with Your exercise of permissions under this License.
|
|
152
|
+
|
|
153
|
+
8. Limitation of Liability. In no event and under no legal theory,
|
|
154
|
+
whether in tort (including negligence), contract, or otherwise,
|
|
155
|
+
unless required by applicable law (such as deliberate and grossly
|
|
156
|
+
negligent acts) or agreed to in writing, shall any Contributor be
|
|
157
|
+
liable to You for damages, including any direct, indirect, special,
|
|
158
|
+
incidental, or consequential damages of any character arising as a
|
|
159
|
+
result of this License or out of the use or inability to use the
|
|
160
|
+
Work (including but not limited to damages for loss of goodwill,
|
|
161
|
+
work stoppage, computer failure or malfunction, or any and all
|
|
162
|
+
other commercial damages or losses), even if such Contributor
|
|
163
|
+
has been advised of the possibility of such damages.
|
|
164
|
+
|
|
165
|
+
9. Accepting Warranty or Additional Liability. While redistributing
|
|
166
|
+
the Work or Derivative Works thereof, You may choose to offer,
|
|
167
|
+
and charge a fee for, acceptance of support, warranty, indemnity,
|
|
168
|
+
or other liability obligations and/or rights consistent with this
|
|
169
|
+
License. However, in accepting such obligations, You may act only
|
|
170
|
+
on Your own behalf and on Your sole responsibility, not on behalf
|
|
171
|
+
of any other Contributor, and only if You agree to indemnify,
|
|
172
|
+
defend, and hold each Contributor harmless for any liability
|
|
173
|
+
incurred by, or claims asserted against, such Contributor by reason
|
|
174
|
+
of your accepting any such warranty or additional liability.
|
|
175
|
+
|
|
176
|
+
END OF TERMS AND CONDITIONS
|
|
177
|
+
|
|
178
|
+
Copyright 2025, Letta authors
|
|
179
|
+
|
|
180
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
181
|
+
you may not use this file except in compliance with the License.
|
|
182
|
+
You may obtain a copy of the License at
|
|
183
|
+
|
|
184
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
185
|
+
|
|
186
|
+
Unless required by applicable law or agreed to in writing, software
|
|
187
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
188
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
189
|
+
See the License for the specific language governing permissions and
|
|
190
|
+
limitations under the License.
|
package/README.md
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
# Letta Code (Research Preview)
|
|
2
|
+
|
|
3
|
+
A self-improving, stateful coding agent that can learn from experience and improve with use.
|
|
4
|
+
|
|
5
|
+
## What is Letta Code?
|
|
6
|
+
|
|
7
|
+
Letta Code is a command-line harness around the stateful Letta [Agents API](https://docs.letta.com/api-reference/overview). You can use Letta Code to create and connect with any Letta agent (even non-coding agents!) - Letta Code simply gives your agents the ability to interact with your local dev environment, directly in your terminal.
|
|
8
|
+
|
|
9
|
+
> [!IMPORTANT]
|
|
10
|
+
> Letta Code is a **research preview** in active development, and may have bugs or unexpected issues. To learn more about the roadmap and chat with the dev team, visit our Discord at [discord.gg/letta](https:/discord.gg/letta). Contributions welcome, join the fun.
|
|
11
|
+
|
|
12
|
+
## Quickstart
|
|
13
|
+
|
|
14
|
+
Get an API key at: [https://app.letta.com](https://app.letta.com/)
|
|
15
|
+
|
|
16
|
+
Install the package via npm:
|
|
17
|
+
```bash
|
|
18
|
+
npm install @letta-ai/letta-code
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Set your API key, and run `letta`:
|
|
22
|
+
```
|
|
23
|
+
export LETTA_API_KEY=...
|
|
24
|
+
letta
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Quickstart (from source)
|
|
28
|
+
|
|
29
|
+
First, install Bun if you don't have it yet: [https://bun.com/docs/installation](https://bun.com/docs/installation)
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
# install deps
|
|
33
|
+
bun install
|
|
34
|
+
|
|
35
|
+
# run Letta Code
|
|
36
|
+
bun run src/index.ts
|
|
37
|
+
|
|
38
|
+
# alternatively, install globally to `letta` executable
|
|
39
|
+
bun add --global .
|
|
40
|
+
|
|
41
|
+
# then, you can run w/ `letta`
|
|
42
|
+
letta
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## Usage
|
|
46
|
+
|
|
47
|
+
### Interactive Mode
|
|
48
|
+
```bash
|
|
49
|
+
letta # Start new session
|
|
50
|
+
letta --continue # Resume last session
|
|
51
|
+
letta --agent <id> # Open specific agent
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
### Headless Mode
|
|
55
|
+
```bash
|
|
56
|
+
letta -p "your prompt" # Run non-interactive
|
|
57
|
+
letta -p "commit changes" --continue # Continue previous session
|
|
58
|
+
letta -p "run tests" --allowedTools "Bash" # Control tool permissions
|
|
59
|
+
letta -p "run tests" --disallowedTools "Bash" # Control tool permissions
|
|
60
|
+
|
|
61
|
+
# Pipe input from stdin
|
|
62
|
+
echo "Explain this code" | letta -p
|
|
63
|
+
cat file.txt | letta -p
|
|
64
|
+
gh pr diff 123 | letta -p --yolo # Review PR changes
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
You can also use the `--tools` flag to control the underlying *attachment* of tools (not just the permissions).
|
|
68
|
+
Compared to disallowing the tool, this will additionally remove the tool schema from the agent's context window.
|
|
69
|
+
```bash
|
|
70
|
+
letta -p "run tests" --tools "Bash,Read" # Only load specific tools
|
|
71
|
+
letta -p "analyze code" --tools "" # No tools (analysis only)
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
### Permissions
|
|
75
|
+
|
|
76
|
+
**Tool selection** (controls which tools are loaded):
|
|
77
|
+
```bash
|
|
78
|
+
--tools "Bash,Read,Write" # Only load these tools
|
|
79
|
+
--tools "" # No tools (conversation only)
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
**Permission overrides** (controls tool access, applies to loaded tools):
|
|
83
|
+
```bash
|
|
84
|
+
--allowedTools "Bash,Read,Write" # Allow specific tools
|
|
85
|
+
--allowedTools "Bash(npm run test:*)" # Allow specific commands
|
|
86
|
+
--disallowedTools "Bash(curl:*)" # Block specific patterns
|
|
87
|
+
--permission-mode acceptEdits # Auto-allow Write/Edit tools
|
|
88
|
+
--permission-mode plan # Read-only mode
|
|
89
|
+
--permission-mode bypassPermissions # Allow all tools (use carefully!)
|
|
90
|
+
--yolo # Alias for --permission-mode bypassPermissions
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
Permission modes:
|
|
94
|
+
- `default` - Standard behavior, prompts for approval
|
|
95
|
+
- `acceptEdits` - Auto-allows Write/Edit/NotebookEdit
|
|
96
|
+
- `plan` - Read-only, allows analysis but blocks modifications
|
|
97
|
+
- `bypassPermissions` - Auto-allows all tools (for trusted environments)
|
|
98
|
+
|
|
99
|
+
Permissions are also configured in `.letta/settings.json`:
|
|
100
|
+
```json
|
|
101
|
+
{
|
|
102
|
+
"permissions": {
|
|
103
|
+
"allow": ["Bash(npm run lint)", "Read(src/**)"],
|
|
104
|
+
"deny": ["Bash(rm -rf:*)", "Read(.env)"]
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
---
|
|
110
|
+
|
|
111
|
+
To distribute:
|
|
112
|
+
```bash
|
|
113
|
+
# build single-file binary
|
|
114
|
+
bun build src/index.ts --compile --outfile letta
|
|
115
|
+
|
|
116
|
+
# optionally copy to bin
|
|
117
|
+
sudo mv letta /usr/local/bin/
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
---
|
|
121
|
+
|
|
122
|
+
Made with 💜 in San Francisco
|
package/bin/letta
ADDED
|
Binary file
|
package/package.json
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@letta-ai/letta-code",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Letta Code CLI for interacting with Letta agents from the terminal.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"letta": "bin/letta"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"LICENSE",
|
|
11
|
+
"README.md",
|
|
12
|
+
"bin",
|
|
13
|
+
"scripts",
|
|
14
|
+
"vendor"
|
|
15
|
+
],
|
|
16
|
+
"repository": {
|
|
17
|
+
"type": "git",
|
|
18
|
+
"url": "https://github.com/letta-ai/letta-code.git"
|
|
19
|
+
},
|
|
20
|
+
"license": "Apache-2.0",
|
|
21
|
+
"publishConfig": {
|
|
22
|
+
"access": "public"
|
|
23
|
+
},
|
|
24
|
+
"devDependencies": {
|
|
25
|
+
"@biomejs/biome": "2.2.5",
|
|
26
|
+
"@types/bun": "latest",
|
|
27
|
+
"@types/diff": "^8.0.0",
|
|
28
|
+
"typescript": "^5.0.0",
|
|
29
|
+
"husky": "9.1.7",
|
|
30
|
+
"lint-staged": "16.2.4"
|
|
31
|
+
},
|
|
32
|
+
"dependencies": {
|
|
33
|
+
"@letta-ai/letta-client": "1.0.0-alpha.2",
|
|
34
|
+
"diff": "^8.0.2",
|
|
35
|
+
"ink": "^5.0.0",
|
|
36
|
+
"ink-spinner": "^5.0.0",
|
|
37
|
+
"ink-text-input": "^5.0.0",
|
|
38
|
+
"minimatch": "^10.0.3",
|
|
39
|
+
"react": "18.2.0"
|
|
40
|
+
},
|
|
41
|
+
"scripts": {
|
|
42
|
+
"lint": "biome check src",
|
|
43
|
+
"fix": "biome check --write src",
|
|
44
|
+
"dev:ui": "bun run src/index.ts",
|
|
45
|
+
"build": "bun build src/index.ts --compile --outfile bin/letta",
|
|
46
|
+
"prepublishOnly": "bun run build",
|
|
47
|
+
"postinstall": "bun scripts/postinstall-patches.js || true"
|
|
48
|
+
},
|
|
49
|
+
"lint-staged": {
|
|
50
|
+
"*.{ts,tsx,js,jsx,json,md}": [
|
|
51
|
+
"biome check --write"
|
|
52
|
+
]
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// Postinstall patcher for vendoring our Ink modifications without patch-package.
|
|
2
|
+
// Copies patched runtime files from ./src/vendor into node_modules.
|
|
3
|
+
|
|
4
|
+
import { copyFileSync, existsSync, mkdirSync } from "node:fs";
|
|
5
|
+
import { createRequire } from "node:module";
|
|
6
|
+
import { dirname, join } from "node:path";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
|
|
9
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
10
|
+
const pkgRoot = dirname(__dirname);
|
|
11
|
+
const require = createRequire(import.meta.url);
|
|
12
|
+
|
|
13
|
+
async function copyToResolved(srcRel, targetSpecifier) {
|
|
14
|
+
const src = join(pkgRoot, srcRel);
|
|
15
|
+
if (!existsSync(src)) return;
|
|
16
|
+
let dest;
|
|
17
|
+
try {
|
|
18
|
+
// Special handling for Ink internals due to package exports
|
|
19
|
+
if (targetSpecifier.startsWith("ink/")) {
|
|
20
|
+
// Resolve root of installed ink package; add robust fallbacks for Bun
|
|
21
|
+
let buildDir;
|
|
22
|
+
try {
|
|
23
|
+
// Prefer import.meta.resolve when available
|
|
24
|
+
const inkEntryUrl = await import.meta.resolve("ink");
|
|
25
|
+
const inkEntryPath = fileURLToPath(inkEntryUrl); // .../node_modules/ink/build/index.js
|
|
26
|
+
buildDir = dirname(inkEntryPath); // .../node_modules/ink/build
|
|
27
|
+
} catch {}
|
|
28
|
+
if (!buildDir) {
|
|
29
|
+
try {
|
|
30
|
+
const inkPkgPath = require.resolve("ink/package.json");
|
|
31
|
+
const inkRoot = dirname(inkPkgPath);
|
|
32
|
+
buildDir = join(inkRoot, "build");
|
|
33
|
+
} catch {}
|
|
34
|
+
}
|
|
35
|
+
if (!buildDir) {
|
|
36
|
+
// Final fallback: assume standard layout relative to project root
|
|
37
|
+
buildDir = join(pkgRoot, "node_modules", "ink", "build");
|
|
38
|
+
}
|
|
39
|
+
const rel = targetSpecifier.replace(/^ink\//, ""); // e.g. build/components/App.js
|
|
40
|
+
const afterBuild = rel.replace(/^build\//, ""); // e.g. components/App.js
|
|
41
|
+
dest = join(buildDir, afterBuild);
|
|
42
|
+
} else if (targetSpecifier.startsWith("ink-text-input/")) {
|
|
43
|
+
// Resolve root of installed ink-text-input in a Node 18+ compatible way
|
|
44
|
+
try {
|
|
45
|
+
const entryUrl = await import.meta.resolve("ink-text-input");
|
|
46
|
+
dest = fileURLToPath(entryUrl); // .../node_modules/ink-text-input/build/index.js
|
|
47
|
+
} catch {
|
|
48
|
+
try {
|
|
49
|
+
const itPkgPath = require.resolve("ink-text-input/package.json");
|
|
50
|
+
const itRoot = dirname(itPkgPath);
|
|
51
|
+
dest = join(itRoot, "build", "index.js");
|
|
52
|
+
} catch {
|
|
53
|
+
// Final fallback
|
|
54
|
+
dest = join(
|
|
55
|
+
pkgRoot,
|
|
56
|
+
"node_modules",
|
|
57
|
+
"ink-text-input",
|
|
58
|
+
"build",
|
|
59
|
+
"index.js",
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
} else {
|
|
64
|
+
dest = require.resolve(targetSpecifier);
|
|
65
|
+
}
|
|
66
|
+
} catch (e) {
|
|
67
|
+
console.warn(
|
|
68
|
+
`[patch] failed to resolve ${targetSpecifier}:`,
|
|
69
|
+
e?.message || e,
|
|
70
|
+
);
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
const destDir = dirname(dest);
|
|
74
|
+
if (!existsSync(destDir)) mkdirSync(destDir, { recursive: true });
|
|
75
|
+
try {
|
|
76
|
+
copyFileSync(src, dest);
|
|
77
|
+
console.log(`[patch] ${srcRel} -> ${dest}`);
|
|
78
|
+
} catch (e) {
|
|
79
|
+
console.warn(
|
|
80
|
+
`[patch] failed to copy ${srcRel} to ${dest}:`,
|
|
81
|
+
e?.message || e,
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Ink internals (resolve actual installed module path)
|
|
87
|
+
await copyToResolved(
|
|
88
|
+
"vendor/ink/build/components/App.js",
|
|
89
|
+
"ink/build/components/App.js",
|
|
90
|
+
);
|
|
91
|
+
await copyToResolved(
|
|
92
|
+
"vendor/ink/build/hooks/use-input.js",
|
|
93
|
+
"ink/build/hooks/use-input.js",
|
|
94
|
+
);
|
|
95
|
+
await copyToResolved(
|
|
96
|
+
"vendor/ink/build/devtools.js",
|
|
97
|
+
"ink/build/devtools.js",
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
// ink-text-input (optional vendor with externalCursorOffset support)
|
|
101
|
+
await copyToResolved(
|
|
102
|
+
"vendor/ink-text-input/build/index.js",
|
|
103
|
+
"ink-text-input/build/index.js",
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
console.log("[patch] Ink runtime patched");
|
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
import { EventEmitter } from 'node:events';
|
|
2
|
+
import process from 'node:process';
|
|
3
|
+
import cliCursor from 'cli-cursor';
|
|
4
|
+
import React, { PureComponent } from 'react';
|
|
5
|
+
import AppContext from './AppContext.js';
|
|
6
|
+
import ErrorOverview from './ErrorOverview.js';
|
|
7
|
+
import FocusContext from './FocusContext.js';
|
|
8
|
+
import StderrContext from './StderrContext.js';
|
|
9
|
+
import StdinContext from './StdinContext.js';
|
|
10
|
+
import StdoutContext from './StdoutContext.js';
|
|
11
|
+
|
|
12
|
+
const tab = '\t';
|
|
13
|
+
const shiftTab = '\u001B[Z';
|
|
14
|
+
const escape = '\u001B';
|
|
15
|
+
export default class App extends PureComponent {
|
|
16
|
+
static displayName = 'InternalApp';
|
|
17
|
+
static getDerivedStateFromError(error) {
|
|
18
|
+
return { error };
|
|
19
|
+
}
|
|
20
|
+
state = {
|
|
21
|
+
isFocusEnabled: true,
|
|
22
|
+
activeFocusId: undefined,
|
|
23
|
+
focusables: [],
|
|
24
|
+
error: undefined,
|
|
25
|
+
};
|
|
26
|
+
rawModeEnabledCount = 0;
|
|
27
|
+
internal_eventEmitter = new EventEmitter();
|
|
28
|
+
isRawModeSupported() {
|
|
29
|
+
return this.props.stdin.isTTY;
|
|
30
|
+
}
|
|
31
|
+
render() {
|
|
32
|
+
return (React.createElement(AppContext.Provider, { value: { exit: this.handleExit } },
|
|
33
|
+
React.createElement(StdinContext.Provider, { value: { stdin: this.props.stdin, setRawMode: this.handleSetRawMode, isRawModeSupported: this.isRawModeSupported(), internal_exitOnCtrlC: this.props.exitOnCtrlC, internal_eventEmitter: this.internal_eventEmitter } },
|
|
34
|
+
React.createElement(StdoutContext.Provider, { value: { stdout: this.props.stdout, write: this.props.writeToStdout } },
|
|
35
|
+
React.createElement(StderrContext.Provider, { value: { stderr: this.props.stderr, write: this.props.writeToStderr } },
|
|
36
|
+
React.createElement(FocusContext.Provider, { value: { activeId: this.state.activeFocusId, add: this.addFocusable, remove: this.removeFocusable, activate: this.activateFocusable, deactivate: this.deactivateFocusable, enableFocus: this.enableFocus, disableFocus: this.disableFocus, focusNext: this.focusNext, focusPrevious: this.focusPrevious, focus: this.focus } }, this.state.error ? (React.createElement(ErrorOverview, { error: this.state.error })) : (this.props.children)))))));
|
|
37
|
+
}
|
|
38
|
+
componentDidMount() {
|
|
39
|
+
cliCursor.hide(this.props.stdout);
|
|
40
|
+
}
|
|
41
|
+
componentWillUnmount() {
|
|
42
|
+
cliCursor.show(this.props.stdout);
|
|
43
|
+
if (this.isRawModeSupported()) {
|
|
44
|
+
this.handleSetRawMode(false);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
componentDidCatch(error) {
|
|
48
|
+
this.handleExit(error);
|
|
49
|
+
}
|
|
50
|
+
handleSetRawMode = (isEnabled) => {
|
|
51
|
+
const { stdin } = this.props;
|
|
52
|
+
if (!this.isRawModeSupported()) {
|
|
53
|
+
if (stdin === process.stdin) {
|
|
54
|
+
throw new Error('Raw mode is not supported on the current process.stdin, which Ink uses as input stream by default.\nRead about how to prevent this error on https://github.com/vadimdemedes/ink/#israwmodesupported');
|
|
55
|
+
}
|
|
56
|
+
else {
|
|
57
|
+
throw new Error('Raw mode is not supported on the stdin provided to Ink.\nRead about how to prevent this error on https://github.com/vadimdemedes/ink/#israwmodesupported');
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
stdin.setEncoding('utf8');
|
|
61
|
+
if (isEnabled) {
|
|
62
|
+
if (this.rawModeEnabledCount === 0) {
|
|
63
|
+
stdin.ref();
|
|
64
|
+
stdin.setRawMode(true);
|
|
65
|
+
stdin.addListener('readable', this.handleReadable);
|
|
66
|
+
// Enable bracketed paste on this TTY
|
|
67
|
+
this.props.stdout?.write('\x1B[?2004h');
|
|
68
|
+
}
|
|
69
|
+
this.rawModeEnabledCount++;
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
if (--this.rawModeEnabledCount === 0) {
|
|
73
|
+
this.props.stdout?.write('\x1B[?2004l');
|
|
74
|
+
stdin.setRawMode(false);
|
|
75
|
+
stdin.removeListener('readable', this.handleReadable);
|
|
76
|
+
stdin.unref();
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
keyParseState = { mode: 'NORMAL', incomplete: '', pasteBuffer: '' };
|
|
80
|
+
fallbackPaste = { aggregating: false, buffer: '', timer: null, lastAt: 0, chunks: 0, bytes: 0, escalated: false, recentTime: 0, recentLen: 0 };
|
|
81
|
+
FALLBACK_NORMAL_MS = 16;
|
|
82
|
+
FALLBACK_PASTE_MS = 150;
|
|
83
|
+
PLACEHOLDER_LINE_THRESHOLD = 5;
|
|
84
|
+
PLACEHOLDER_CHAR_THRESHOLD = 500;
|
|
85
|
+
FALLBACK_START_LEN_THRESHOLD = 200;
|
|
86
|
+
parseChunk = (state, chunk) => {
|
|
87
|
+
const START = '\x1B[200~';
|
|
88
|
+
const END = '\x1B[201~';
|
|
89
|
+
const events = [];
|
|
90
|
+
let next = { ...state };
|
|
91
|
+
let buf = (next.incomplete || '') + (chunk || '');
|
|
92
|
+
next.incomplete = '';
|
|
93
|
+
const pushText = (text) => {
|
|
94
|
+
if (text && text.length > 0) {
|
|
95
|
+
events.push({ type: 'text', value: text });
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
if (next.mode === 'NORMAL') {
|
|
99
|
+
let offset = 0;
|
|
100
|
+
while (offset < buf.length) {
|
|
101
|
+
const startIdx = buf.indexOf(START, offset);
|
|
102
|
+
if (startIdx === -1) {
|
|
103
|
+
const remainder = buf.slice(offset);
|
|
104
|
+
let keep = 0;
|
|
105
|
+
const max = Math.min(remainder.length, START.length - 1);
|
|
106
|
+
// Only keep potential START prefixes of length >= 2 (e.g., "\x1B[") to avoid swallowing a bare ESC
|
|
107
|
+
for (let i = max; i > 1; i--) {
|
|
108
|
+
if (START.startsWith(remainder.slice(-i))) {
|
|
109
|
+
keep = i;
|
|
110
|
+
break;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
if (remainder.length > keep) {
|
|
114
|
+
pushText(remainder.slice(0, remainder.length - keep));
|
|
115
|
+
}
|
|
116
|
+
next.incomplete = remainder.slice(remainder.length - keep);
|
|
117
|
+
break;
|
|
118
|
+
}
|
|
119
|
+
if (startIdx > offset) {
|
|
120
|
+
pushText(buf.slice(offset, startIdx));
|
|
121
|
+
}
|
|
122
|
+
offset = startIdx + START.length;
|
|
123
|
+
const endIdx = buf.indexOf(END, offset);
|
|
124
|
+
if (endIdx !== -1) {
|
|
125
|
+
const content = buf.slice(offset, endIdx);
|
|
126
|
+
events.push({ type: 'paste', value: content });
|
|
127
|
+
offset = endIdx + END.length;
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
next.mode = 'IN_PASTE';
|
|
131
|
+
next.pasteBuffer = buf.slice(offset);
|
|
132
|
+
break;
|
|
133
|
+
}
|
|
134
|
+
return [events, next];
|
|
135
|
+
}
|
|
136
|
+
if (next.mode === 'IN_PASTE') {
|
|
137
|
+
next.pasteBuffer += buf;
|
|
138
|
+
const endIdx = next.pasteBuffer.indexOf(END);
|
|
139
|
+
if (endIdx === -1) {
|
|
140
|
+
return [events, next];
|
|
141
|
+
}
|
|
142
|
+
const content = next.pasteBuffer.slice(0, endIdx);
|
|
143
|
+
events.push({ type: 'paste', value: content });
|
|
144
|
+
const after = next.pasteBuffer.slice(endIdx + END.length);
|
|
145
|
+
next.mode = 'NORMAL';
|
|
146
|
+
next.pasteBuffer = '';
|
|
147
|
+
const [moreEvents, finalState] = this.parseChunk(next, after);
|
|
148
|
+
return [events.concat(moreEvents), finalState];
|
|
149
|
+
}
|
|
150
|
+
return [events, next];
|
|
151
|
+
};
|
|
152
|
+
countLines = (text) => {
|
|
153
|
+
if (!text)
|
|
154
|
+
return 0;
|
|
155
|
+
const m = text.match(/\r\n|\r|\n/g);
|
|
156
|
+
return (m ? m.length : 0);
|
|
157
|
+
};
|
|
158
|
+
fallbackStart = () => {
|
|
159
|
+
this.fallbackStop();
|
|
160
|
+
this.fallbackPaste.aggregating = true;
|
|
161
|
+
this.fallbackPaste.buffer = '';
|
|
162
|
+
this.fallbackPaste.chunks = 0;
|
|
163
|
+
this.fallbackPaste.bytes = 0;
|
|
164
|
+
this.fallbackPaste.escalated = false;
|
|
165
|
+
this.fallbackPaste.lastAt = Date.now();
|
|
166
|
+
this.fallbackPaste.timer = setTimeout(this.fallbackFlush, this.FALLBACK_NORMAL_MS);
|
|
167
|
+
};
|
|
168
|
+
fallbackSchedule = (ms) => {
|
|
169
|
+
if (this.fallbackPaste.timer)
|
|
170
|
+
clearTimeout(this.fallbackPaste.timer);
|
|
171
|
+
this.fallbackPaste.timer = setTimeout(this.fallbackFlush, ms);
|
|
172
|
+
this.fallbackPaste.lastAt = Date.now();
|
|
173
|
+
};
|
|
174
|
+
fallbackStop = () => {
|
|
175
|
+
if (this.fallbackPaste.timer)
|
|
176
|
+
clearTimeout(this.fallbackPaste.timer);
|
|
177
|
+
this.fallbackPaste.timer = null;
|
|
178
|
+
this.fallbackPaste.aggregating = false;
|
|
179
|
+
};
|
|
180
|
+
fallbackFlush = () => {
|
|
181
|
+
const txt = this.fallbackPaste.buffer;
|
|
182
|
+
this.fallbackStop();
|
|
183
|
+
if (!txt)
|
|
184
|
+
return;
|
|
185
|
+
const lines = this.countLines(txt);
|
|
186
|
+
const isPaste = this.fallbackPaste.escalated || (lines > this.PLACEHOLDER_LINE_THRESHOLD) || (txt.length > this.PLACEHOLDER_CHAR_THRESHOLD);
|
|
187
|
+
if (isPaste) {
|
|
188
|
+
const pasteEvent = { sequence: txt, raw: txt, isPasted: true, name: '', ctrl: false, meta: false, shift: false };
|
|
189
|
+
this.internal_eventEmitter.emit('input', pasteEvent);
|
|
190
|
+
}
|
|
191
|
+
else {
|
|
192
|
+
this.handleInput(txt);
|
|
193
|
+
this.internal_eventEmitter.emit('input', txt);
|
|
194
|
+
}
|
|
195
|
+
this.fallbackPaste.buffer = '';
|
|
196
|
+
this.fallbackPaste.chunks = 0;
|
|
197
|
+
this.fallbackPaste.bytes = 0;
|
|
198
|
+
this.fallbackPaste.escalated = false;
|
|
199
|
+
};
|
|
200
|
+
handleReadable = () => {
|
|
201
|
+
let chunk;
|
|
202
|
+
while ((chunk = this.props.stdin.read()) !== null) {
|
|
203
|
+
const [events, nextState] = this.parseChunk(this.keyParseState, chunk);
|
|
204
|
+
this.keyParseState = nextState;
|
|
205
|
+
for (const evt of events) {
|
|
206
|
+
if (evt.type === 'paste') {
|
|
207
|
+
if (this.fallbackPaste.aggregating) {
|
|
208
|
+
this.fallbackFlush();
|
|
209
|
+
}
|
|
210
|
+
const content = evt.value;
|
|
211
|
+
const pasteEvent = { sequence: content, raw: content, isPasted: true, name: '', ctrl: false, meta: false, shift: false };
|
|
212
|
+
this.internal_eventEmitter.emit('input', pasteEvent);
|
|
213
|
+
}
|
|
214
|
+
else if (evt.type === 'text') {
|
|
215
|
+
const text = evt.value;
|
|
216
|
+
if (!text)
|
|
217
|
+
continue;
|
|
218
|
+
const hasNewline = /\r|\n/.test(text);
|
|
219
|
+
if (this.fallbackPaste.aggregating) {
|
|
220
|
+
this.fallbackPaste.buffer += text;
|
|
221
|
+
this.fallbackPaste.chunks += 1;
|
|
222
|
+
this.fallbackPaste.bytes += text.length;
|
|
223
|
+
if (!this.fallbackPaste.escalated) {
|
|
224
|
+
if (this.fallbackPaste.buffer.length >= 128) {
|
|
225
|
+
this.fallbackPaste.escalated = true;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
this.fallbackSchedule(this.fallbackPaste.escalated ? this.FALLBACK_PASTE_MS : this.FALLBACK_NORMAL_MS);
|
|
229
|
+
continue;
|
|
230
|
+
}
|
|
231
|
+
const now = Date.now();
|
|
232
|
+
const quickCombo = (now - this.fallbackPaste.recentTime) <= 16 && (this.fallbackPaste.recentLen + text.length) >= 128;
|
|
233
|
+
if (text.length >= 128 || quickCombo) {
|
|
234
|
+
this.fallbackStart();
|
|
235
|
+
this.fallbackPaste.buffer += text;
|
|
236
|
+
this.fallbackPaste.chunks = 1;
|
|
237
|
+
this.fallbackPaste.bytes = text.length;
|
|
238
|
+
this.fallbackPaste.escalated = text.length >= 128;
|
|
239
|
+
this.fallbackSchedule(this.FALLBACK_PASTE_MS);
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
242
|
+
this.handleInput(text);
|
|
243
|
+
this.internal_eventEmitter.emit('input', text);
|
|
244
|
+
this.fallbackPaste.recentTime = Date.now();
|
|
245
|
+
this.fallbackPaste.recentLen = text.length;
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
};
|
|
251
|
+
handleInput = (input) => {
|
|
252
|
+
if (input === '\x03' && this.props.exitOnCtrlC) {
|
|
253
|
+
this.handleExit();
|
|
254
|
+
}
|
|
255
|
+
// Disable ESC-based focus clearing to avoid consuming the first Escape
|
|
256
|
+
// if (input === escape && this.state.activeFocusId) {
|
|
257
|
+
// this.setState({ activeFocusId: undefined });
|
|
258
|
+
// }
|
|
259
|
+
if (this.state.isFocusEnabled && this.state.focusables.length > 0) {
|
|
260
|
+
if (input === tab) {
|
|
261
|
+
this.focusNext();
|
|
262
|
+
}
|
|
263
|
+
if (input === shiftTab) {
|
|
264
|
+
this.focusPrevious();
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
};
|
|
268
|
+
handleExit = (error) => {
|
|
269
|
+
if (this.isRawModeSupported()) {
|
|
270
|
+
this.handleSetRawMode(false);
|
|
271
|
+
}
|
|
272
|
+
this.props.onExit(error);
|
|
273
|
+
};
|
|
274
|
+
enableFocus = () => {
|
|
275
|
+
this.setState({ isFocusEnabled: true });
|
|
276
|
+
};
|
|
277
|
+
disableFocus = () => {
|
|
278
|
+
this.setState({ isFocusEnabled: false });
|
|
279
|
+
};
|
|
280
|
+
focus = (id) => {
|
|
281
|
+
this.setState(previousState => {
|
|
282
|
+
const hasFocusableId = previousState.focusables.some(focusable => focusable?.id === id);
|
|
283
|
+
if (!hasFocusableId) {
|
|
284
|
+
return previousState;
|
|
285
|
+
}
|
|
286
|
+
return { activeFocusId: id };
|
|
287
|
+
});
|
|
288
|
+
};
|
|
289
|
+
focusNext = () => {
|
|
290
|
+
this.setState(previousState => {
|
|
291
|
+
const firstFocusableId = previousState.focusables.find(focusable => focusable.isActive)?.id;
|
|
292
|
+
const nextFocusableId = this.findNextFocusable(previousState);
|
|
293
|
+
return { activeFocusId: nextFocusableId ?? firstFocusableId };
|
|
294
|
+
});
|
|
295
|
+
};
|
|
296
|
+
focusPrevious = () => {
|
|
297
|
+
this.setState(previousState => {
|
|
298
|
+
const lastFocusableId = previousState.focusables.findLast(focusable => focusable.isActive)?.id;
|
|
299
|
+
const previousFocusableId = this.findPreviousFocusable(previousState);
|
|
300
|
+
return { activeFocusId: previousFocusableId ?? lastFocusableId };
|
|
301
|
+
});
|
|
302
|
+
};
|
|
303
|
+
addFocusable = (id, { autoFocus }) => {
|
|
304
|
+
this.setState(previousState => {
|
|
305
|
+
let nextFocusId = previousState.activeFocusId;
|
|
306
|
+
if (!nextFocusId && autoFocus) {
|
|
307
|
+
nextFocusId = id;
|
|
308
|
+
}
|
|
309
|
+
return { activeFocusId: nextFocusId, focusables: [...previousState.focusables, { id, isActive: true }] };
|
|
310
|
+
});
|
|
311
|
+
};
|
|
312
|
+
removeFocusable = (id) => {
|
|
313
|
+
this.setState(previousState => ({ activeFocusId: previousState.activeFocusId === id ? undefined : previousState.activeFocusId, focusables: previousState.focusables.filter(focusable => focusable.id !== id) }));
|
|
314
|
+
};
|
|
315
|
+
activateFocusable = (id) => {
|
|
316
|
+
this.setState(previousState => ({ focusables: previousState.focusables.map(focusable => (focusable.id !== id ? focusable : { id, isActive: true })) }));
|
|
317
|
+
};
|
|
318
|
+
deactivateFocusable = (id) => {
|
|
319
|
+
this.setState(previousState => ({ activeFocusId: previousState.activeFocusId === id ? undefined : previousState.activeFocusId, focusables: previousState.focusables.map(focusable => (focusable.id !== id ? focusable : { id, isActive: false })) }));
|
|
320
|
+
};
|
|
321
|
+
findNextFocusable = (state) => {
|
|
322
|
+
const activeIndex = state.focusables.findIndex(focusable => {
|
|
323
|
+
return focusable.id === state.activeFocusId;
|
|
324
|
+
});
|
|
325
|
+
for (let index = activeIndex + 1; index < state.focusables.length; index++) {
|
|
326
|
+
const focusable = state.focusables[index];
|
|
327
|
+
if (focusable?.isActive) {
|
|
328
|
+
return focusable.id;
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
return undefined;
|
|
332
|
+
};
|
|
333
|
+
findPreviousFocusable = (state) => {
|
|
334
|
+
const activeIndex = state.focusables.findIndex(focusable => {
|
|
335
|
+
return focusable.id === state.activeFocusId;
|
|
336
|
+
});
|
|
337
|
+
for (let index = activeIndex - 1; index >= 0; index--) {
|
|
338
|
+
const focusable = state.focusables[index];
|
|
339
|
+
if (focusable?.isActive) {
|
|
340
|
+
return focusable.id;
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
return undefined;
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
//# sourceMappingURL=App.js.map
|
|
347
|
+
|
|
348
|
+
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { useEffect } from 'react';
|
|
2
|
+
import parseKeypress, { nonAlphanumericKeys } from '../parse-keypress.js';
|
|
3
|
+
import reconciler from '../reconciler.js';
|
|
4
|
+
import useStdin from './use-stdin.js';
|
|
5
|
+
|
|
6
|
+
// Patched for bracketed paste: propagate "isPasted" and avoid leaking ESC sequences
|
|
7
|
+
const useInput = (inputHandler, options = {}) => {
|
|
8
|
+
const { stdin, setRawMode, internal_exitOnCtrlC, internal_eventEmitter } = useStdin();
|
|
9
|
+
|
|
10
|
+
useEffect(() => {
|
|
11
|
+
if (options.isActive === false) {
|
|
12
|
+
return;
|
|
13
|
+
}
|
|
14
|
+
setRawMode(true);
|
|
15
|
+
return () => {
|
|
16
|
+
setRawMode(false);
|
|
17
|
+
};
|
|
18
|
+
}, [options.isActive, setRawMode]);
|
|
19
|
+
|
|
20
|
+
useEffect(() => {
|
|
21
|
+
if (options.isActive === false) {
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const handleData = (data) => {
|
|
26
|
+
// Handle bracketed paste events emitted by Ink stdin manager
|
|
27
|
+
if (data && typeof data === 'object' && data.isPasted) {
|
|
28
|
+
const key = {
|
|
29
|
+
upArrow: false,
|
|
30
|
+
downArrow: false,
|
|
31
|
+
leftArrow: false,
|
|
32
|
+
rightArrow: false,
|
|
33
|
+
pageDown: false,
|
|
34
|
+
pageUp: false,
|
|
35
|
+
return: false,
|
|
36
|
+
escape: false,
|
|
37
|
+
ctrl: false,
|
|
38
|
+
shift: false,
|
|
39
|
+
tab: false,
|
|
40
|
+
backspace: false,
|
|
41
|
+
delete: false,
|
|
42
|
+
meta: false,
|
|
43
|
+
isPasted: true
|
|
44
|
+
};
|
|
45
|
+
reconciler.batchedUpdates(() => {
|
|
46
|
+
inputHandler(data.sequence || data.raw || '', key);
|
|
47
|
+
});
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const keypress = parseKeypress(data);
|
|
52
|
+
const key = {
|
|
53
|
+
upArrow: keypress.name === 'up',
|
|
54
|
+
downArrow: keypress.name === 'down',
|
|
55
|
+
leftArrow: keypress.name === 'left',
|
|
56
|
+
rightArrow: keypress.name === 'right',
|
|
57
|
+
pageDown: keypress.name === 'pagedown',
|
|
58
|
+
pageUp: keypress.name === 'pageup',
|
|
59
|
+
return: keypress.name === 'return',
|
|
60
|
+
escape: keypress.name === 'escape',
|
|
61
|
+
ctrl: keypress.ctrl,
|
|
62
|
+
shift: keypress.shift,
|
|
63
|
+
tab: keypress.name === 'tab',
|
|
64
|
+
backspace: keypress.name === 'backspace',
|
|
65
|
+
delete: keypress.name === 'delete',
|
|
66
|
+
meta: keypress.meta || keypress.name === 'escape' || keypress.option,
|
|
67
|
+
isPasted: false
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
let input = keypress.ctrl ? keypress.name : keypress.sequence;
|
|
71
|
+
const seq = typeof keypress.sequence === 'string' ? keypress.sequence : '';
|
|
72
|
+
// Filter xterm focus in/out sequences (ESC[I / ESC[O)
|
|
73
|
+
if (seq === '\u001B[I' || seq === '\u001B[O' || input === '[I' || input === '[O' || /^(?:\[I|\[O)+$/.test(input || '')) {
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (nonAlphanumericKeys.includes(keypress.name)) {
|
|
78
|
+
input = '';
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (input.length === 1 && typeof input[0] === 'string' && /[A-Z]/.test(input[0])) {
|
|
82
|
+
key.shift = true;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (!(input === 'c' && key.ctrl) || !internal_exitOnCtrlC) {
|
|
86
|
+
reconciler.batchedUpdates(() => {
|
|
87
|
+
inputHandler(input, key);
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
internal_eventEmitter?.on('input', handleData);
|
|
93
|
+
return () => {
|
|
94
|
+
internal_eventEmitter?.removeListener('input', handleData);
|
|
95
|
+
};
|
|
96
|
+
}, [options.isActive, stdin, internal_exitOnCtrlC, inputHandler]);
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
export default useInput;
|
|
100
|
+
|
|
101
|
+
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import { Text, useInput } from 'ink';
|
|
3
|
+
import React, { useEffect, useState } from 'react';
|
|
4
|
+
|
|
5
|
+
function TextInput({ value: originalValue, placeholder = '', focus = true, mask, highlightPastedText = false, showCursor = true, onChange, onSubmit, externalCursorOffset, onCursorOffsetChange }) {
|
|
6
|
+
const [state, setState] = useState({ cursorOffset: (originalValue || '').length, cursorWidth: 0 });
|
|
7
|
+
const { cursorOffset, cursorWidth } = state;
|
|
8
|
+
useEffect(() => {
|
|
9
|
+
setState(previousState => {
|
|
10
|
+
if (!focus || !showCursor) {
|
|
11
|
+
return previousState;
|
|
12
|
+
}
|
|
13
|
+
const newValue = originalValue || '';
|
|
14
|
+
if (previousState.cursorOffset > newValue.length - 1) {
|
|
15
|
+
return { cursorOffset: newValue.length, cursorWidth: 0 };
|
|
16
|
+
}
|
|
17
|
+
return previousState;
|
|
18
|
+
});
|
|
19
|
+
}, [originalValue, focus, showCursor]);
|
|
20
|
+
useEffect(() => {
|
|
21
|
+
if (typeof externalCursorOffset === 'number') {
|
|
22
|
+
const newValue = originalValue || '';
|
|
23
|
+
const clamped = Math.max(0, Math.min(externalCursorOffset, newValue.length));
|
|
24
|
+
setState(prev => ({ cursorOffset: clamped, cursorWidth: 0 }));
|
|
25
|
+
if (typeof onCursorOffsetChange === 'function') onCursorOffsetChange(clamped);
|
|
26
|
+
}
|
|
27
|
+
}, [externalCursorOffset, originalValue, onCursorOffsetChange]);
|
|
28
|
+
const cursorActualWidth = highlightPastedText ? cursorWidth : 0;
|
|
29
|
+
const value = mask ? mask.repeat(originalValue.length) : originalValue;
|
|
30
|
+
let renderedValue = value;
|
|
31
|
+
let renderedPlaceholder = placeholder ? chalk.grey(placeholder) : undefined;
|
|
32
|
+
if (showCursor && focus) {
|
|
33
|
+
renderedPlaceholder = placeholder.length > 0 ? chalk.inverse(placeholder[0]) + chalk.grey(placeholder.slice(1)) : chalk.inverse(' ');
|
|
34
|
+
renderedValue = value.length > 0 ? '' : chalk.inverse(' ');
|
|
35
|
+
let i = 0;
|
|
36
|
+
for (const char of value) {
|
|
37
|
+
renderedValue += i >= cursorOffset - cursorActualWidth && i <= cursorOffset ? chalk.inverse(char) : char;
|
|
38
|
+
i++;
|
|
39
|
+
}
|
|
40
|
+
if (value.length > 0 && cursorOffset === value.length) {
|
|
41
|
+
renderedValue += chalk.inverse(' ');
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
useInput((input, key) => {
|
|
45
|
+
if (key && key.isPasted) {
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
// Treat Escape as a control key (don't insert into value)
|
|
49
|
+
if (key.escape || key.upArrow || key.downArrow || (key.ctrl && input === 'c') || key.tab || (key.shift && key.tab)) {
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
if (key.return) {
|
|
53
|
+
if (onSubmit) {
|
|
54
|
+
onSubmit(originalValue);
|
|
55
|
+
}
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
let nextCursorOffset = cursorOffset;
|
|
59
|
+
let nextValue = originalValue;
|
|
60
|
+
let nextCursorWidth = 0;
|
|
61
|
+
if (key.leftArrow) {
|
|
62
|
+
if (showCursor) {
|
|
63
|
+
nextCursorOffset--;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
else if (key.rightArrow) {
|
|
67
|
+
if (showCursor) {
|
|
68
|
+
nextCursorOffset++;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
else if (key.backspace || key.delete) {
|
|
72
|
+
if (cursorOffset > 0) {
|
|
73
|
+
nextValue = originalValue.slice(0, cursorOffset - 1) + originalValue.slice(cursorOffset, originalValue.length);
|
|
74
|
+
nextCursorOffset--;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
else if (key.ctrl && input === 'a') {
|
|
78
|
+
// CTRL-A: jump to beginning of line
|
|
79
|
+
if (showCursor) {
|
|
80
|
+
nextCursorOffset = 0;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
else if (key.ctrl && input === 'e') {
|
|
84
|
+
// CTRL-E: jump to end of line
|
|
85
|
+
if (showCursor) {
|
|
86
|
+
nextCursorOffset = originalValue.length;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
else {
|
|
90
|
+
nextValue = originalValue.slice(0, cursorOffset) + input + originalValue.slice(cursorOffset, originalValue.length);
|
|
91
|
+
nextCursorOffset += input.length;
|
|
92
|
+
if (input.length > 1) {
|
|
93
|
+
nextCursorWidth = input.length;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
if (cursorOffset < 0) {
|
|
97
|
+
nextCursorOffset = 0;
|
|
98
|
+
}
|
|
99
|
+
if (cursorOffset > originalValue.length) {
|
|
100
|
+
nextCursorOffset = originalValue.length;
|
|
101
|
+
}
|
|
102
|
+
setState({ cursorOffset: nextCursorOffset, cursorWidth: nextCursorWidth });
|
|
103
|
+
if (typeof onCursorOffsetChange === 'function') onCursorOffsetChange(nextCursorOffset);
|
|
104
|
+
if (nextValue !== originalValue) {
|
|
105
|
+
onChange(nextValue);
|
|
106
|
+
}
|
|
107
|
+
}, { isActive: focus });
|
|
108
|
+
return (React.createElement(Text, null, placeholder ? (value.length > 0 ? renderedValue : renderedPlaceholder) : renderedValue));
|
|
109
|
+
}
|
|
110
|
+
export default TextInput;
|
|
111
|
+
export function UncontrolledTextInput({ initialValue = '', ...props }) {
|
|
112
|
+
const [value, setValue] = useState(initialValue);
|
|
113
|
+
return React.createElement(TextInput, { ...props, value: value, onChange: setValue });
|
|
114
|
+
}
|
|
115
|
+
|