@zhongqian97-code/ecode 0.2.4 → 0.2.6
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 +106 -13
- package/dist/index.js +242 -33
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,25 +1,20 @@
|
|
|
1
1
|
# ecode
|
|
2
2
|
|
|
3
|
-
A minimal [Claude Code](https://claude.ai/code) clone —
|
|
3
|
+
A minimal [Claude Code](https://claude.ai/code) clone — fullscreen TUI with streaming LLM responses, bash tool calling, Emacs keybindings, and a skill system.
|
|
4
4
|
|
|
5
5
|
## Install
|
|
6
6
|
|
|
7
7
|
```bash
|
|
8
|
-
npm install -g @
|
|
8
|
+
npm install -g @zhongqian97-code/ecode
|
|
9
9
|
```
|
|
10
10
|
|
|
11
|
-
##
|
|
11
|
+
## Quick start
|
|
12
12
|
|
|
13
13
|
```bash
|
|
14
|
-
# Set your API key (OpenAI-compatible endpoint)
|
|
15
14
|
export ECODE_API_KEY=sk-...
|
|
16
|
-
|
|
17
|
-
# Start the REPL
|
|
18
15
|
ecode
|
|
19
16
|
```
|
|
20
17
|
|
|
21
|
-
Type your message, press Enter. Type `exit` to quit.
|
|
22
|
-
|
|
23
18
|
## Configuration
|
|
24
19
|
|
|
25
20
|
Priority: **env vars > config file > defaults**
|
|
@@ -31,19 +26,33 @@ Priority: **env vars > config file > defaults**
|
|
|
31
26
|
| `ECODE_API_KEY` | *(required)* | API key |
|
|
32
27
|
| `ECODE_BASE_URL` | `https://api.openai.com/v1` | Base URL (any OpenAI-compatible endpoint) |
|
|
33
28
|
| `ECODE_MODEL` | `gpt-4o` | Model name |
|
|
29
|
+
| `ECODE_LOG_DIR` | *(disabled)* | Directory for session logs (JSONL) |
|
|
34
30
|
|
|
35
31
|
### Config file
|
|
36
32
|
|
|
37
|
-
`~/.ecode/config.json`
|
|
33
|
+
`~/.ecode/config.json` — all fields are optional, only set what you need.
|
|
38
34
|
|
|
39
35
|
```json
|
|
40
36
|
{
|
|
41
37
|
"apiKey": "sk-...",
|
|
42
38
|
"baseUrl": "https://api.openai.com/v1",
|
|
43
|
-
"model": "gpt-4o"
|
|
39
|
+
"model": "gpt-4o",
|
|
40
|
+
"logDir": "~/.ecode/logs",
|
|
41
|
+
"contextLimit": 128000,
|
|
42
|
+
"dangerousPatterns": [
|
|
43
|
+
"rm -rf", "sudo", "chmod", "chown",
|
|
44
|
+
"mkfs", "dd", "fdisk",
|
|
45
|
+
"kill", "pkill", "killall",
|
|
46
|
+
"reboot", "shutdown", "halt",
|
|
47
|
+
"curl -X DELETE", "wget --delete-after"
|
|
48
|
+
]
|
|
44
49
|
}
|
|
45
50
|
```
|
|
46
51
|
|
|
52
|
+
**`contextLimit`** — override automatic context window detection (tokens). Useful for unlisted or self-hosted models.
|
|
53
|
+
|
|
54
|
+
**`dangerousPatterns`** — list of command prefixes that require double confirmation. Setting this field replaces the entire default list.
|
|
55
|
+
|
|
47
56
|
### Using with other providers
|
|
48
57
|
|
|
49
58
|
```bash
|
|
@@ -53,6 +62,12 @@ export ECODE_API_KEY=sk-...
|
|
|
53
62
|
export ECODE_MODEL=deepseek-chat
|
|
54
63
|
ecode
|
|
55
64
|
|
|
65
|
+
# Anthropic Claude (via proxy or compatible gateway)
|
|
66
|
+
export ECODE_BASE_URL=https://your-openai-proxy/v1
|
|
67
|
+
export ECODE_API_KEY=sk-ant-...
|
|
68
|
+
export ECODE_MODEL=claude-sonnet-4-6
|
|
69
|
+
ecode
|
|
70
|
+
|
|
56
71
|
# Local Ollama
|
|
57
72
|
export ECODE_BASE_URL=http://localhost:11434/v1
|
|
58
73
|
export ECODE_API_KEY=ollama
|
|
@@ -60,15 +75,93 @@ export ECODE_MODEL=llama3
|
|
|
60
75
|
ecode
|
|
61
76
|
```
|
|
62
77
|
|
|
78
|
+
Context window sizes are pre-configured for common models (GPT-4o, Claude, DeepSeek, o1/o3). Unknown models default to 128K.
|
|
79
|
+
|
|
80
|
+
## Keyboard shortcuts
|
|
81
|
+
|
|
82
|
+
### Submitting and navigation
|
|
83
|
+
|
|
84
|
+
| Key | Action |
|
|
85
|
+
|---|---|
|
|
86
|
+
| `Enter` | Submit message |
|
|
87
|
+
| `Shift+Enter` | Insert newline (multi-line input) |
|
|
88
|
+
| `Tab` | Toggle tool call / thinking expansion |
|
|
89
|
+
| `PageUp` / `PageDown` | Scroll conversation history |
|
|
90
|
+
| `Ctrl+V` | Scroll down (same as PageDown) |
|
|
91
|
+
| `Alt+V` | Scroll up (same as PageUp) |
|
|
92
|
+
| `Ctrl+P` | Previous command (input history) |
|
|
93
|
+
| `Ctrl+N` | Next command (input history) |
|
|
94
|
+
|
|
95
|
+
### Emacs cursor movement
|
|
96
|
+
|
|
97
|
+
| Key | Action |
|
|
98
|
+
|---|---|
|
|
99
|
+
| `Ctrl+A` | Jump to start of line |
|
|
100
|
+
| `Ctrl+E` | Jump to end of line |
|
|
101
|
+
| `Ctrl+B` / `←` | Move left one character |
|
|
102
|
+
| `Ctrl+F` / `→` | Move right one character |
|
|
103
|
+
| `Alt+B` | Move left one word |
|
|
104
|
+
| `Alt+F` | Move right one word |
|
|
105
|
+
|
|
106
|
+
### Emacs editing
|
|
107
|
+
|
|
108
|
+
| Key | Action |
|
|
109
|
+
|---|---|
|
|
110
|
+
| `Backspace` | Delete character before cursor |
|
|
111
|
+
| `Ctrl+D` | Delete character at cursor |
|
|
112
|
+
| `Ctrl+K` | Kill from cursor to end of line |
|
|
113
|
+
| `Ctrl+U` | Kill from start of line to cursor |
|
|
114
|
+
| `Ctrl+W` | Kill word backward |
|
|
115
|
+
|
|
63
116
|
## Bash tool calling
|
|
64
117
|
|
|
65
118
|
ecode gives the LLM access to a bash tool. Commands are classified into three tiers:
|
|
66
119
|
|
|
67
120
|
| Tier | Examples | Behavior |
|
|
68
121
|
|---|---|---|
|
|
69
|
-
| **Allow** | `ls`, `cat`, `pwd`, `echo` | Auto-execute, no prompt |
|
|
70
|
-
| **Normal** | `git status`, `npm install` | Single confirmation |
|
|
71
|
-
| **Danger** | `rm -rf`, `sudo`, `chmod` | Double confirmation |
|
|
122
|
+
| **Allow** | `ls`, `cat`, `pwd`, `echo`, `head`, `tail`, `wc`, `date`, `whoami`, `which`, `env` | Auto-execute, no prompt |
|
|
123
|
+
| **Normal** | `git status`, `npm install`, `grep` | Single confirmation |
|
|
124
|
+
| **Danger** | `rm -rf`, `sudo`, `chmod`, `kill`, `reboot` | Double confirmation |
|
|
125
|
+
|
|
126
|
+
The danger list is fully customizable via `dangerousPatterns` in the config file.
|
|
127
|
+
|
|
128
|
+
## Skills (slash commands)
|
|
129
|
+
|
|
130
|
+
Type `/` to see available skills with Tab autocomplete. Skills inject structured instructions into the LLM context.
|
|
131
|
+
|
|
132
|
+
| Skill | Description |
|
|
133
|
+
|---|---|
|
|
134
|
+
| `/plan` | Restate requirements and create a step-by-step implementation plan |
|
|
135
|
+
| `/tdd` | Test-driven development with red-green-refactor loop |
|
|
136
|
+
| `/diagnose` | Disciplined debug loop: reproduce → minimise → hypothesise → instrument → fix |
|
|
137
|
+
| `/grill-me` | Relentless interview to stress-test a plan or design |
|
|
138
|
+
| `/grill-with-docs` | Grilling session that challenges plans against the project's domain model |
|
|
139
|
+
| `/improve-codebase-architecture` | Find deepening opportunities and architectural friction |
|
|
140
|
+
| `/security-review` | Security vulnerability scan of pending changes |
|
|
141
|
+
| `/search-first` | Research-before-coding workflow |
|
|
142
|
+
| `/zoom-out` | Get a higher-level map of the relevant modules and callers |
|
|
143
|
+
| `/to-prd` | Turn current context into a PRD |
|
|
144
|
+
| `/to-issues` | Break a plan into independently-grabbable issues |
|
|
145
|
+
| `/triage` | Move issues through a triage state machine |
|
|
146
|
+
| `/write-a-skill` | Create new agent skills |
|
|
147
|
+
| `/caveman` | Ultra-compressed mode (~75% token reduction) |
|
|
148
|
+
|
|
149
|
+
Run `/setup-matt-pocock-skills` once to configure the issue tracker and triage vocabulary for your repo.
|
|
150
|
+
|
|
151
|
+
## Session logging
|
|
152
|
+
|
|
153
|
+
Enable JSONL session logs to replay or analyze conversations:
|
|
154
|
+
|
|
155
|
+
```bash
|
|
156
|
+
# via env var
|
|
157
|
+
export ECODE_LOG_DIR=~/.ecode/logs
|
|
158
|
+
ecode
|
|
159
|
+
|
|
160
|
+
# via config file
|
|
161
|
+
# "logDir": "~/.ecode/logs"
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
Each session writes a timestamped `.jsonl` file. Each line is a JSON object with `role`, `content`, and `timestamp`.
|
|
72
165
|
|
|
73
166
|
## Requirements
|
|
74
167
|
|
package/dist/index.js
CHANGED
|
@@ -96,7 +96,7 @@ function loadConfig() {
|
|
|
96
96
|
|
|
97
97
|
// src/ui/App.tsx
|
|
98
98
|
import { useState as useState3, useCallback, useRef as useRef2, useEffect as useEffect3, useMemo } from "react";
|
|
99
|
-
import { Box as Box5, useInput as useInput2, useStdout } from "ink";
|
|
99
|
+
import { Box as Box5, useInput as useInput2, useStdout, useStdin } from "ink";
|
|
100
100
|
|
|
101
101
|
// src/llm.ts
|
|
102
102
|
import OpenAI from "openai";
|
|
@@ -597,19 +597,45 @@ import { Box as Box3, Text as Text3, useInput } from "ink";
|
|
|
597
597
|
import { jsx as jsx3, jsxs as jsxs3 } from "react/jsx-runtime";
|
|
598
598
|
var CURSOR_CHAR = "\u258C";
|
|
599
599
|
var BLINK_INTERVAL_MS = 530;
|
|
600
|
+
function wordBackward(s, pos) {
|
|
601
|
+
let i = pos;
|
|
602
|
+
while (i > 0 && s[i - 1] === " ") {
|
|
603
|
+
i--;
|
|
604
|
+
}
|
|
605
|
+
while (i > 0 && s[i - 1] !== " ") {
|
|
606
|
+
i--;
|
|
607
|
+
}
|
|
608
|
+
return i;
|
|
609
|
+
}
|
|
610
|
+
function wordForward(s, pos) {
|
|
611
|
+
let i = pos;
|
|
612
|
+
const len = s.length;
|
|
613
|
+
while (i < len && s[i] !== " ") {
|
|
614
|
+
i++;
|
|
615
|
+
}
|
|
616
|
+
while (i < len && s[i] === " ") {
|
|
617
|
+
i++;
|
|
618
|
+
}
|
|
619
|
+
return i;
|
|
620
|
+
}
|
|
600
621
|
var Input = forwardRef(function Input2({ isActive, onSubmit, onChange, placeholder }, ref) {
|
|
601
|
-
const [
|
|
622
|
+
const [value, setValue] = useState2("");
|
|
623
|
+
const [cursorPos, setCursorPos] = useState2(0);
|
|
602
624
|
const [cursorVisible, setCursorVisible] = useState2(true);
|
|
603
|
-
const
|
|
604
|
-
|
|
625
|
+
const valueRef = useRef(value);
|
|
626
|
+
valueRef.current = value;
|
|
627
|
+
const cursorPosRef = useRef(cursorPos);
|
|
628
|
+
cursorPosRef.current = cursorPos;
|
|
605
629
|
const onChangeRef = useRef(onChange);
|
|
606
630
|
onChangeRef.current = onChange;
|
|
607
631
|
const onSubmitRef = useRef(onSubmit);
|
|
608
632
|
onSubmitRef.current = onSubmit;
|
|
609
633
|
useImperativeHandle(ref, () => ({
|
|
610
634
|
fill(text) {
|
|
611
|
-
|
|
612
|
-
|
|
635
|
+
valueRef.current = text;
|
|
636
|
+
cursorPosRef.current = text.length;
|
|
637
|
+
setValue(text);
|
|
638
|
+
setCursorPos(text.length);
|
|
613
639
|
onChangeRef.current?.(text);
|
|
614
640
|
}
|
|
615
641
|
}));
|
|
@@ -625,49 +651,124 @@ var Input = forwardRef(function Input2({ isActive, onSubmit, onChange, placehold
|
|
|
625
651
|
clearInterval(timer);
|
|
626
652
|
};
|
|
627
653
|
}, [isActive]);
|
|
654
|
+
function setValueSync(newValue) {
|
|
655
|
+
valueRef.current = newValue;
|
|
656
|
+
setValue(newValue);
|
|
657
|
+
}
|
|
658
|
+
function setCursorPosSync(newPos) {
|
|
659
|
+
cursorPosRef.current = newPos;
|
|
660
|
+
setCursorPos(newPos);
|
|
661
|
+
}
|
|
628
662
|
useInput(
|
|
629
663
|
(input, key) => {
|
|
630
|
-
const
|
|
664
|
+
const v = valueRef.current;
|
|
665
|
+
const pos = cursorPosRef.current;
|
|
631
666
|
if (key.return && key.shift) {
|
|
632
|
-
const
|
|
633
|
-
|
|
634
|
-
|
|
667
|
+
const newValue = v.slice(0, pos) + "\n" + v.slice(pos);
|
|
668
|
+
setValueSync(newValue);
|
|
669
|
+
setCursorPosSync(pos + 1);
|
|
670
|
+
onChangeRef.current?.(newValue);
|
|
635
671
|
return;
|
|
636
672
|
}
|
|
637
673
|
if (key.return) {
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
674
|
+
onSubmitRef.current(v);
|
|
675
|
+
setValueSync("");
|
|
676
|
+
setCursorPosSync(0);
|
|
641
677
|
onChangeRef.current?.("");
|
|
642
678
|
return;
|
|
643
679
|
}
|
|
644
680
|
if (key.backspace || key.delete) {
|
|
645
|
-
|
|
646
|
-
const
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
681
|
+
if (pos === 0) return;
|
|
682
|
+
const newValue = v.slice(0, pos - 1) + v.slice(pos);
|
|
683
|
+
setValueSync(newValue);
|
|
684
|
+
setCursorPosSync(pos - 1);
|
|
685
|
+
onChangeRef.current?.(newValue);
|
|
686
|
+
return;
|
|
687
|
+
}
|
|
688
|
+
if (key.ctrl) {
|
|
689
|
+
switch (input) {
|
|
690
|
+
case "a": {
|
|
691
|
+
setCursorPosSync(0);
|
|
692
|
+
return;
|
|
693
|
+
}
|
|
694
|
+
case "e": {
|
|
695
|
+
setCursorPosSync(v.length);
|
|
696
|
+
return;
|
|
697
|
+
}
|
|
698
|
+
case "b": {
|
|
699
|
+
setCursorPosSync(Math.max(0, pos - 1));
|
|
700
|
+
return;
|
|
701
|
+
}
|
|
702
|
+
case "f": {
|
|
703
|
+
setCursorPosSync(Math.min(v.length, pos + 1));
|
|
704
|
+
return;
|
|
705
|
+
}
|
|
706
|
+
case "k": {
|
|
707
|
+
const nextNl = v.indexOf("\n", pos);
|
|
708
|
+
const lineEnd = nextNl === -1 ? v.length : nextNl;
|
|
709
|
+
const newValue = v.slice(0, pos) + v.slice(lineEnd);
|
|
710
|
+
setValueSync(newValue);
|
|
711
|
+
onChangeRef.current?.(newValue);
|
|
712
|
+
return;
|
|
713
|
+
}
|
|
714
|
+
case "u": {
|
|
715
|
+
const newValue = v.slice(pos);
|
|
716
|
+
setValueSync(newValue);
|
|
717
|
+
setCursorPosSync(0);
|
|
718
|
+
onChangeRef.current?.(newValue);
|
|
719
|
+
return;
|
|
720
|
+
}
|
|
721
|
+
case "w": {
|
|
722
|
+
const newPos = wordBackward(v, pos);
|
|
723
|
+
const newValue = v.slice(0, newPos) + v.slice(pos);
|
|
724
|
+
setValueSync(newValue);
|
|
725
|
+
setCursorPosSync(newPos);
|
|
726
|
+
onChangeRef.current?.(newValue);
|
|
727
|
+
return;
|
|
728
|
+
}
|
|
729
|
+
case "d": {
|
|
730
|
+
if (pos >= v.length) return;
|
|
731
|
+
const newValue = v.slice(0, pos) + v.slice(pos + 1);
|
|
732
|
+
setValueSync(newValue);
|
|
733
|
+
onChangeRef.current?.(newValue);
|
|
734
|
+
return;
|
|
735
|
+
}
|
|
736
|
+
default:
|
|
737
|
+
return;
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
if (key.meta) {
|
|
741
|
+
if (input === "b") {
|
|
742
|
+
setCursorPosSync(wordBackward(v, pos));
|
|
653
743
|
return;
|
|
654
744
|
}
|
|
655
|
-
|
|
656
|
-
|
|
745
|
+
if (input === "f") {
|
|
746
|
+
setCursorPosSync(wordForward(v, pos));
|
|
747
|
+
return;
|
|
748
|
+
}
|
|
749
|
+
return;
|
|
750
|
+
}
|
|
751
|
+
if (key.leftArrow) {
|
|
752
|
+
setCursorPosSync(Math.max(0, pos - 1));
|
|
753
|
+
return;
|
|
754
|
+
}
|
|
755
|
+
if (key.rightArrow) {
|
|
756
|
+
setCursorPosSync(Math.min(v.length, pos + 1));
|
|
657
757
|
return;
|
|
658
758
|
}
|
|
659
|
-
if (key.
|
|
759
|
+
if (key.escape || key.upArrow || key.downArrow || key.tab || key.pageUp || key.pageDown) {
|
|
660
760
|
return;
|
|
661
761
|
}
|
|
662
762
|
if (input.length > 0) {
|
|
663
|
-
const
|
|
664
|
-
|
|
665
|
-
|
|
763
|
+
const newValue = v.slice(0, pos) + input + v.slice(pos);
|
|
764
|
+
setValueSync(newValue);
|
|
765
|
+
setCursorPosSync(pos + input.length);
|
|
766
|
+
onChangeRef.current?.(newValue);
|
|
666
767
|
}
|
|
667
768
|
},
|
|
668
769
|
{ isActive }
|
|
669
770
|
);
|
|
670
|
-
const isEmpty =
|
|
771
|
+
const isEmpty = value === "";
|
|
671
772
|
const renderLines = () => {
|
|
672
773
|
if (isEmpty && placeholder) {
|
|
673
774
|
return /* @__PURE__ */ jsxs3(Box3, { children: [
|
|
@@ -676,19 +777,41 @@ var Input = forwardRef(function Input2({ isActive, onSubmit, onChange, placehold
|
|
|
676
777
|
isActive && cursorVisible && /* @__PURE__ */ jsx3(Text3, { color: "cyan", children: CURSOR_CHAR })
|
|
677
778
|
] });
|
|
678
779
|
}
|
|
780
|
+
const lines = value.split("\n");
|
|
781
|
+
let remaining = cursorPos;
|
|
782
|
+
let cursorLine = 0;
|
|
783
|
+
let cursorCol = 0;
|
|
784
|
+
for (let i = 0; i < lines.length; i++) {
|
|
785
|
+
const lineLen = lines[i].length;
|
|
786
|
+
if (remaining <= lineLen) {
|
|
787
|
+
cursorLine = i;
|
|
788
|
+
cursorCol = remaining;
|
|
789
|
+
break;
|
|
790
|
+
}
|
|
791
|
+
remaining -= lineLen + 1;
|
|
792
|
+
}
|
|
679
793
|
return /* @__PURE__ */ jsx3(Box3, { flexDirection: "column", children: lines.map((line, idx) => {
|
|
680
|
-
const isLastLine = idx === lines.length - 1;
|
|
681
794
|
const prefix = idx === 0 ? "> " : " ";
|
|
795
|
+
const showCursor = isActive && cursorVisible && idx === cursorLine;
|
|
796
|
+
if (!showCursor) {
|
|
797
|
+
return /* @__PURE__ */ jsxs3(Box3, { children: [
|
|
798
|
+
/* @__PURE__ */ jsx3(Text3, { color: "cyan", children: prefix }),
|
|
799
|
+
/* @__PURE__ */ jsx3(Text3, { children: line })
|
|
800
|
+
] }, idx);
|
|
801
|
+
}
|
|
802
|
+
const before = line.slice(0, cursorCol);
|
|
803
|
+
const after = line.slice(cursorCol);
|
|
682
804
|
return /* @__PURE__ */ jsxs3(Box3, { children: [
|
|
683
805
|
/* @__PURE__ */ jsx3(Text3, { color: "cyan", children: prefix }),
|
|
684
|
-
/* @__PURE__ */ jsx3(Text3, { children:
|
|
685
|
-
|
|
806
|
+
/* @__PURE__ */ jsx3(Text3, { children: before }),
|
|
807
|
+
/* @__PURE__ */ jsx3(Text3, { color: "cyan", children: CURSOR_CHAR }),
|
|
808
|
+
/* @__PURE__ */ jsx3(Text3, { children: after })
|
|
686
809
|
] }, idx);
|
|
687
810
|
}) });
|
|
688
811
|
};
|
|
689
812
|
return /* @__PURE__ */ jsx3(Box3, { children: isActive ? renderLines() : /* @__PURE__ */ jsxs3(Box3, { children: [
|
|
690
813
|
/* @__PURE__ */ jsx3(Text3, { dimColor: true, children: "> " }),
|
|
691
|
-
/* @__PURE__ */ jsx3(Text3, { dimColor: true, children: isEmpty ? placeholder ?? "" :
|
|
814
|
+
/* @__PURE__ */ jsx3(Text3, { dimColor: true, children: isEmpty ? placeholder ?? "" : value })
|
|
692
815
|
] }) });
|
|
693
816
|
});
|
|
694
817
|
var Input_default = Input;
|
|
@@ -747,10 +870,32 @@ function dismiss(state) {
|
|
|
747
870
|
return { ...state, dismissed: true };
|
|
748
871
|
}
|
|
749
872
|
|
|
873
|
+
// src/ui/mouseInput.ts
|
|
874
|
+
var SGR_MOUSE_RE = /^\x1b\[<(\d+);\d+;\d+[Mm]/;
|
|
875
|
+
function parseMouseScroll(data) {
|
|
876
|
+
const s = typeof data === "string" ? data : data.toString("binary");
|
|
877
|
+
if (!s) return null;
|
|
878
|
+
const sgrMatch = SGR_MOUSE_RE.exec(s);
|
|
879
|
+
if (sgrMatch) {
|
|
880
|
+
const cb = parseInt(sgrMatch[1], 10);
|
|
881
|
+
if (cb === 64) return { direction: "up" };
|
|
882
|
+
if (cb === 65) return { direction: "down" };
|
|
883
|
+
return null;
|
|
884
|
+
}
|
|
885
|
+
if (s.length >= 6 && s.charCodeAt(0) === 27 && s[1] === "[" && s[2] === "M") {
|
|
886
|
+
const buttonByte = s.charCodeAt(3);
|
|
887
|
+
if (buttonByte === 96) return { direction: "up" };
|
|
888
|
+
if (buttonByte === 97) return { direction: "down" };
|
|
889
|
+
return null;
|
|
890
|
+
}
|
|
891
|
+
return null;
|
|
892
|
+
}
|
|
893
|
+
|
|
750
894
|
// src/ui/App.tsx
|
|
751
895
|
import { jsx as jsx5, jsxs as jsxs5 } from "react/jsx-runtime";
|
|
752
896
|
function App({ config: config2, version: version2, autoMode: autoMode2 = false, registry: registry2, llmClient }) {
|
|
753
897
|
const { stdout } = useStdout();
|
|
898
|
+
const { stdin } = useStdin();
|
|
754
899
|
const historyMaxHeight = Math.max(5, (stdout?.rows ?? 24) - 4);
|
|
755
900
|
const [messages, setMessages] = useState3([]);
|
|
756
901
|
const [status, setStatus] = useState3("idle");
|
|
@@ -764,6 +909,11 @@ function App({ config: config2, version: version2, autoMode: autoMode2 = false,
|
|
|
764
909
|
const [confirmPrompt, setConfirmPrompt] = useState3(void 0);
|
|
765
910
|
const [expandTools, setExpandTools] = useState3(false);
|
|
766
911
|
const [scrollOffset, setScrollOffset] = useState3(0);
|
|
912
|
+
const [inputHistory, setInputHistory] = useState3([]);
|
|
913
|
+
const inputHistoryRef = useRef2([]);
|
|
914
|
+
inputHistoryRef.current = inputHistory;
|
|
915
|
+
const historyIndexRef = useRef2(-1);
|
|
916
|
+
const isNavigatingHistoryRef = useRef2(false);
|
|
767
917
|
const totalLines = useMemo(() => {
|
|
768
918
|
const visible = messages.filter((m) => m.role !== "system");
|
|
769
919
|
return visible.reduce(
|
|
@@ -771,6 +921,8 @@ function App({ config: config2, version: version2, autoMode: autoMode2 = false,
|
|
|
771
921
|
0
|
|
772
922
|
);
|
|
773
923
|
}, [messages, expandTools, stdout?.columns]);
|
|
924
|
+
const totalLinesRef = useRef2(totalLines);
|
|
925
|
+
totalLinesRef.current = totalLines;
|
|
774
926
|
const pendingConfirmRef = useRef2(null);
|
|
775
927
|
const llmRef = useRef2(llmClient ?? createLLMClient(config2));
|
|
776
928
|
const inputRef = useRef2(null);
|
|
@@ -799,7 +951,25 @@ function App({ config: config2, version: version2, autoMode: autoMode2 = false,
|
|
|
799
951
|
}
|
|
800
952
|
loggedCountRef.current = messages.length;
|
|
801
953
|
}, [messages]);
|
|
802
|
-
|
|
954
|
+
useEffect3(() => {
|
|
955
|
+
if (!stdin || !stdout) return;
|
|
956
|
+
stdout.write("\x1B[?1000h\x1B[?1006h");
|
|
957
|
+
const onMouseData = (data) => {
|
|
958
|
+
const event = parseMouseScroll(data);
|
|
959
|
+
if (!event) return;
|
|
960
|
+
if (event.direction === "up") {
|
|
961
|
+
setScrollOffset((prev) => Math.min(prev + 3, Math.max(0, totalLinesRef.current - 1)));
|
|
962
|
+
} else {
|
|
963
|
+
setScrollOffset((prev) => Math.max(0, prev - 3));
|
|
964
|
+
}
|
|
965
|
+
};
|
|
966
|
+
stdin.on("data", onMouseData);
|
|
967
|
+
return () => {
|
|
968
|
+
stdout.write("\x1B[?1000l\x1B[?1006l");
|
|
969
|
+
stdin.off("data", onMouseData);
|
|
970
|
+
};
|
|
971
|
+
}, [stdin, stdout]);
|
|
972
|
+
useInput2((input, key) => {
|
|
803
973
|
const skillList = registry2?.list() ?? [];
|
|
804
974
|
const suggestions = computeSuggestions(skillList, acState);
|
|
805
975
|
const open = isOpen(acState, suggestions);
|
|
@@ -837,6 +1007,38 @@ function App({ config: config2, version: version2, autoMode: autoMode2 = false,
|
|
|
837
1007
|
setScrollOffset((prev) => Math.max(0, prev - scrollStep));
|
|
838
1008
|
return;
|
|
839
1009
|
}
|
|
1010
|
+
if (key.ctrl && input === "v") {
|
|
1011
|
+
setScrollOffset((prev) => Math.max(0, prev - scrollStep));
|
|
1012
|
+
return;
|
|
1013
|
+
}
|
|
1014
|
+
if (key.meta && input === "v") {
|
|
1015
|
+
setScrollOffset((prev) => Math.min(prev + scrollStep, Math.max(0, totalLines - 1)));
|
|
1016
|
+
return;
|
|
1017
|
+
}
|
|
1018
|
+
if (key.ctrl && input === "p") {
|
|
1019
|
+
const history = inputHistoryRef.current;
|
|
1020
|
+
const newIndex = Math.min(historyIndexRef.current + 1, history.length - 1);
|
|
1021
|
+
if (newIndex >= 0 && newIndex < history.length) {
|
|
1022
|
+
historyIndexRef.current = newIndex;
|
|
1023
|
+
isNavigatingHistoryRef.current = true;
|
|
1024
|
+
inputRef.current?.fill(history[newIndex]);
|
|
1025
|
+
}
|
|
1026
|
+
return;
|
|
1027
|
+
}
|
|
1028
|
+
if (key.ctrl && input === "n") {
|
|
1029
|
+
const history = inputHistoryRef.current;
|
|
1030
|
+
if (historyIndexRef.current > 0) {
|
|
1031
|
+
const newIndex = historyIndexRef.current - 1;
|
|
1032
|
+
historyIndexRef.current = newIndex;
|
|
1033
|
+
isNavigatingHistoryRef.current = true;
|
|
1034
|
+
inputRef.current?.fill(history[newIndex]);
|
|
1035
|
+
} else if (historyIndexRef.current === 0) {
|
|
1036
|
+
historyIndexRef.current = -1;
|
|
1037
|
+
isNavigatingHistoryRef.current = true;
|
|
1038
|
+
inputRef.current?.fill("");
|
|
1039
|
+
}
|
|
1040
|
+
return;
|
|
1041
|
+
}
|
|
840
1042
|
});
|
|
841
1043
|
const confirm = useCallback((prompt) => {
|
|
842
1044
|
return new Promise((resolve2) => {
|
|
@@ -980,6 +1182,8 @@ function App({ config: config2, version: version2, autoMode: autoMode2 = false,
|
|
|
980
1182
|
return;
|
|
981
1183
|
}
|
|
982
1184
|
if (!trimmed) return;
|
|
1185
|
+
setInputHistory((prev) => [trimmed, ...prev.slice(0, 99)]);
|
|
1186
|
+
historyIndexRef.current = -1;
|
|
983
1187
|
setScrollOffset(0);
|
|
984
1188
|
if (registry2) {
|
|
985
1189
|
const skillResult = handleSkillInput(trimmed, registry2);
|
|
@@ -1021,6 +1225,11 @@ function App({ config: config2, version: version2, autoMode: autoMode2 = false,
|
|
|
1021
1225
|
);
|
|
1022
1226
|
const isInputActive = status === "idle" || status === "awaiting_confirm";
|
|
1023
1227
|
const handleInputTextChange = useCallback((text) => {
|
|
1228
|
+
if (isNavigatingHistoryRef.current) {
|
|
1229
|
+
isNavigatingHistoryRef.current = false;
|
|
1230
|
+
} else {
|
|
1231
|
+
historyIndexRef.current = -1;
|
|
1232
|
+
}
|
|
1024
1233
|
if (status !== "awaiting_confirm") {
|
|
1025
1234
|
setAcState((prev) => handleInputChange(prev, text));
|
|
1026
1235
|
}
|