@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.
Files changed (3) hide show
  1. package/README.md +106 -13
  2. package/dist/index.js +242 -33
  3. 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 — REPL interface with streaming LLM responses and bash tool calling.
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 @zhongqian-code/ecode
8
+ npm install -g @zhongqian97-code/ecode
9
9
  ```
10
10
 
11
- ## Usage
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 [lines, setLines] = useState2([""]);
622
+ const [value, setValue] = useState2("");
623
+ const [cursorPos, setCursorPos] = useState2(0);
602
624
  const [cursorVisible, setCursorVisible] = useState2(true);
603
- const linesRef = useRef(lines);
604
- linesRef.current = lines;
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
- const newLines = text ? text.split("\n") : [""];
612
- setLines(newLines);
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 currentLines = linesRef.current;
664
+ const v = valueRef.current;
665
+ const pos = cursorPosRef.current;
631
666
  if (key.return && key.shift) {
632
- const newLines = [...currentLines, ""];
633
- setLines(newLines);
634
- onChangeRef.current?.(newLines.join("\n"));
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
- const text = currentLines.join("\n");
639
- onSubmitRef.current(text);
640
- setLines([""]);
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
- const lastIdx = currentLines.length - 1;
646
- const lastLine = currentLines[lastIdx];
647
- let newLines;
648
- if (lastLine.length > 0) {
649
- newLines = [...currentLines.slice(0, lastIdx), lastLine.slice(0, -1)];
650
- } else if (currentLines.length > 1) {
651
- newLines = currentLines.slice(0, -1);
652
- } else {
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
- setLines(newLines);
656
- onChangeRef.current?.(newLines.join("\n"));
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.ctrl || key.escape || key.upArrow || key.downArrow || key.leftArrow || key.rightArrow || key.tab || key.pageUp || key.pageDown) {
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 newLines = [...currentLines.slice(0, -1), currentLines[currentLines.length - 1] + input];
664
- setLines(newLines);
665
- onChangeRef.current?.(newLines.join("\n"));
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 = lines.every((line) => line === "");
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: line }),
685
- isActive && isLastLine && cursorVisible && /* @__PURE__ */ jsx3(Text3, { color: "cyan", children: CURSOR_CHAR })
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 ?? "" : lines.join(" ") })
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
- useInput2((_input, key) => {
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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhongqian97-code/ecode",
3
- "version": "0.2.4",
3
+ "version": "0.2.6",
4
4
  "description": "A minimal Claude Code clone with REPL interface and bash tool calling",
5
5
  "type": "module",
6
6
  "author": "zhongqian97-code",