@beast01/tcurl 1.0.0 → 1.0.1

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 CHANGED
@@ -39,13 +39,14 @@ tcurl --theme ember # start with the Ember theme
39
39
  tcurl --help
40
40
  ```
41
41
 
42
- ### Keyboard
42
+ ### Keyboard — vim/nvim motions everywhere
43
43
 
44
44
  **List view**
45
45
 
46
46
  | Key | Action |
47
47
  | ------------ | ------------------------------- |
48
- | `↑` / `↓` | Move selection |
48
+ | `j` / `k` | Move selection down / up |
49
+ | `gg` / `G` | First / last request |
49
50
  | `n` | New request |
50
51
  | `Enter` / `e`| Edit request |
51
52
  | `s` | Send request |
@@ -55,25 +56,43 @@ tcurl --help
55
56
  | `?` | Help |
56
57
  | `q` / `Ctrl+C` | Quit |
57
58
 
58
- **Editor**
59
+ **Editor — field navigation**
59
60
 
60
61
  | Key | Action |
61
62
  | ------------------ | -------------------------------------------- |
62
- | `↑` / `↓` / `Tab` | Move between fields |
63
- | `←` / `→` / `Space`| Change method, body type, toggles |
64
- | `Enter` | Edit the focused field |
65
- | `Ctrl+S` | Save a multi-line field (body, headers…) |
63
+ | `j` / `k` / `Tab` | Move between fields |
64
+ | `gg` / `G` | First / last field |
65
+ | `h` / `l` | Change method, body type, toggles |
66
+ | `i` / `a` / `Enter`| Edit the focused field |
66
67
  | `Esc` | Back to list (auto-saves) |
67
68
 
69
+ **Field editor — modal (INSERT / NORMAL, like nvim)**
70
+
71
+ Fields open in **INSERT** mode so you can type right away; press `Esc` for
72
+ **NORMAL** mode and vim motions.
73
+
74
+ | Key | Action |
75
+ | ------------------- | ---------------------------------------- |
76
+ | `Esc` | INSERT → NORMAL |
77
+ | `i` `a` `I` `A` `o` `O` | NORMAL → INSERT (at cursor, after, line start/end, open line) |
78
+ | `h` `j` `k` `l` | Move by char / line |
79
+ | `w` / `b` / `e` | Word forward / back / end |
80
+ | `0` / `$` | Line start / end |
81
+ | `gg` / `G` | Buffer start / end |
82
+ | `x` / `dd` | Delete char / line |
83
+ | `Enter` / `Ctrl+S` | Save the field |
84
+ | `Esc` (in NORMAL) | Cancel the edit |
85
+
68
86
  **Response**
69
87
 
70
- | Key | Action |
71
- | --------------- | ------------------- |
72
- | `↑`/`↓`/`j`/`k` | Scroll |
73
- | `PgUp`/`PgDn` | Page scroll |
74
- | `g` / `G` | Jump to top/bottom |
75
- | `Tab` / `h` | Body Headers |
76
- | `Esc` | Back |
88
+ | Key | Action |
89
+ | ----------------- | --------------------- |
90
+ | `j` / `k` | Scroll down / up |
91
+ | `Ctrl+d` / `Ctrl+u` | Half-page down / up |
92
+ | `Ctrl+f` / `Ctrl+b` | Full-page down / up |
93
+ | `g` / `G` | Top / bottom |
94
+ | `h` / `l` / `Tab` | Body ↔ Headers |
95
+ | `q` / `Esc` | Back |
77
96
 
78
97
  ## Features
79
98
 
@@ -90,6 +109,9 @@ tcurl --help
90
109
  with status, timing, and size.
91
110
  - **Persistence** — requests are stored as JSON and reused across sessions.
92
111
  - **Export** — copy any saved request as an equivalent `curl` command.
112
+ - **Vim/nvim motions** — `j`/`k`, `gg`/`G`, `h`/`l` navigation throughout, plus
113
+ a fully modal (INSERT / NORMAL) field editor with `w`/`b`/`e`, `0`/`$`,
114
+ `x`, `dd`, `o`/`O`, and `Ctrl+d`/`Ctrl+u` scrolling.
93
115
 
94
116
  ## Where is my data?
95
117
 
package/dist/app.js CHANGED
@@ -1,4 +1,4 @@
1
- import React, { useEffect, useState } from 'react';
1
+ import React, { useEffect, useRef, useState } from 'react';
2
2
  import { Box, Text, useApp, useInput, useStdout } from 'ink';
3
3
  import { getTheme, themes } from './theme.js';
4
4
  import { defaultRequest, saveStore } from './storage.js';
@@ -38,6 +38,7 @@ export function App({ initialStore, exitState, }) {
38
38
  const [response, setResponse] = useState(null);
39
39
  const [loading, setLoading] = useState(false);
40
40
  const [flash, setFlash] = useState(null);
41
+ const pendingG = useRef(false);
41
42
  const theme = getTheme(store.theme);
42
43
  const requests = store.requests;
43
44
  function persist(next) {
@@ -89,6 +90,22 @@ export function App({ initialStore, exitState, }) {
89
90
  persist({ ...store, theme: next });
90
91
  return;
91
92
  }
93
+ // gg → first, G → last (vim).
94
+ if (pendingG.current) {
95
+ pendingG.current = false;
96
+ if (input === 'g') {
97
+ setSelected(0);
98
+ return;
99
+ }
100
+ }
101
+ if (input === 'g') {
102
+ pendingG.current = true;
103
+ return;
104
+ }
105
+ if (input === 'G') {
106
+ setSelected(Math.max(0, requests.length - 1));
107
+ return;
108
+ }
92
109
  if (key.upArrow || input === 'k') {
93
110
  setSelected((s) => Math.max(0, s - 1));
94
111
  return;
@@ -2,9 +2,10 @@ import React from 'react';
2
2
  import { Box, Text, useInput } from 'ink';
3
3
  const SECTIONS = [
4
4
  {
5
- title: 'List view',
5
+ title: 'List view (vim keys)',
6
6
  rows: [
7
- [' / ', 'Move selection'],
7
+ ['j / k', 'Move selection down / up'],
8
+ ['gg / G', 'First / last request'],
8
9
  ['n', 'New request'],
9
10
  ['Enter / e', 'Edit request'],
10
11
  ['s', 'Send request'],
@@ -16,23 +17,38 @@ const SECTIONS = [
16
17
  ],
17
18
  },
18
19
  {
19
- title: 'Editor',
20
+ title: 'Editor (field navigation)',
20
21
  rows: [
21
- [' / / Tab', 'Move between fields'],
22
- [' / → / Space', 'Change method, body type, toggles'],
23
- ['Enter', 'Edit the focused field'],
24
- ['Ctrl+S', 'Save multi-line field'],
22
+ ['j / k / Tab', 'Move between fields'],
23
+ ['gg / G', 'First / last field'],
24
+ ['h / l', 'Change method, body type, toggles'],
25
+ ['i / a / Enter', 'Edit the focused field'],
25
26
  ['Esc', 'Back to list (auto-saves)'],
26
27
  ],
27
28
  },
28
29
  {
29
- title: 'Response',
30
+ title: 'Field editor — modal (INSERT / NORMAL)',
30
31
  rows: [
31
- ['↑ / ↓ / j / k', 'Scroll'],
32
- ['PgUp / PgDn', 'Page scroll'],
32
+ ['Esc', 'INSERT → NORMAL'],
33
+ ['i a I A o O', 'NORMAL → INSERT (various)'],
34
+ ['h j k l', 'Move by char / line'],
35
+ ['w / b / e', 'Word forward / back / end'],
36
+ ['0 / $', 'Line start / end'],
37
+ ['gg / G', 'Buffer start / end'],
38
+ ['x / dd', 'Delete char / line'],
39
+ ['Enter / Ctrl+S', 'Save field'],
40
+ ['Esc (in NORMAL)', 'Cancel edit'],
41
+ ],
42
+ },
43
+ {
44
+ title: 'Response (vim keys)',
45
+ rows: [
46
+ ['j / k', 'Scroll down / up'],
47
+ ['Ctrl+d / Ctrl+u', 'Half-page down / up'],
48
+ ['Ctrl+f / Ctrl+b', 'Full-page down / up'],
33
49
  ['g / G', 'Top / bottom'],
34
- ['Tab / h', 'Switch body / headers'],
35
- ['Esc', 'Back'],
50
+ ['h / l / Tab', 'Switch body / headers'],
51
+ ['q / Esc', 'Back'],
36
52
  ],
37
53
  },
38
54
  ];
@@ -1,4 +1,4 @@
1
- import React, { useState } from 'react';
1
+ import React, { useRef, useState } from 'react';
2
2
  import { Box, Text, useInput } from 'ink';
3
3
  import { HTTP_METHODS } from '../types.js';
4
4
  import { methodColor } from '../theme.js';
@@ -9,6 +9,7 @@ const AUTH_TYPES = ['none', 'bearer', 'basic'];
9
9
  export function RequestEditor({ theme, request, isActive, onChange, onSend, onBack, }) {
10
10
  const [focus, setFocus] = useState(0);
11
11
  const [editingKey, setEditingKey] = useState(null);
12
+ const pending = useRef(false);
12
13
  const fields = buildFields(request, theme);
13
14
  const editing = editingKey !== null;
14
15
  useInput((input, key) => {
@@ -16,23 +17,41 @@ export function RequestEditor({ theme, request, isActive, onChange, onSend, onBa
16
17
  onBack();
17
18
  return;
18
19
  }
19
- if (key.upArrow || (key.shift && key.tab)) {
20
+ // gg first field, G → last field (vim).
21
+ if (pending.current) {
22
+ pending.current = false;
23
+ if (input === 'g') {
24
+ setFocus(0);
25
+ return;
26
+ }
27
+ }
28
+ if (input === 'g') {
29
+ pending.current = true;
30
+ return;
31
+ }
32
+ if (input === 'G') {
33
+ setFocus(fields.length - 1);
34
+ return;
35
+ }
36
+ if (key.upArrow || input === 'k' || (key.shift && key.tab)) {
20
37
  setFocus((f) => (f - 1 + fields.length) % fields.length);
21
38
  return;
22
39
  }
23
- if (key.downArrow || key.tab) {
40
+ if (key.downArrow || input === 'j' || key.tab) {
24
41
  setFocus((f) => (f + 1) % fields.length);
25
42
  return;
26
43
  }
27
44
  const field = fields[focus];
28
45
  if (!field)
29
46
  return;
30
- if (key.leftArrow || key.rightArrow) {
31
- const dir = key.leftArrow ? -1 : 1;
47
+ // h/l (and arrows) change cycles/toggles.
48
+ if (key.leftArrow || key.rightArrow || input === 'h' || input === 'l') {
49
+ const dir = key.leftArrow || input === 'h' ? -1 : 1;
32
50
  applyCycle(field.key, dir);
33
51
  return;
34
52
  }
35
- if (key.return || input === ' ') {
53
+ // Enter / i / a / Space edit or activate the focused field.
54
+ if (key.return || input === ' ' || input === 'i' || input === 'a') {
36
55
  activate(field);
37
56
  return;
38
57
  }
@@ -136,7 +155,7 @@ export function RequestEditor({ theme, request, isActive, onChange, onSend, onBa
136
155
  React.createElement(Text, { color: f.color ?? (focused ? theme.fg : theme.fgDim), wrap: "truncate-end" }, f.display)));
137
156
  }),
138
157
  React.createElement(Box, { height: 1 }),
139
- React.createElement(Text, { color: theme.muted }, "\u2191\u2193 move \u00B7 \u2190\u2192/Space change \u00B7 Enter edit \u00B7 Esc back")));
158
+ React.createElement(Text, { color: theme.muted }, "j/k move \u00B7 gg/G ends \u00B7 h/l change \u00B7 i/Enter edit \u00B7 Esc back")));
140
159
  }
141
160
  function buildFields(request, theme) {
142
161
  const fields = [
@@ -21,7 +21,7 @@ export function ResponseViewer({ theme, response, isActive, onBack, width, heigh
21
21
  onBack();
22
22
  return;
23
23
  }
24
- if (input === 'h' || input === 'b' || key.tab) {
24
+ if ((!key.ctrl && (input === 'h' || input === 'l')) || key.tab) {
25
25
  setTab((t) => (t === 'body' ? 'headers' : 'body'));
26
26
  setScroll(0);
27
27
  return;
@@ -30,10 +30,15 @@ export function ResponseViewer({ theme, response, isActive, onBack, width, heigh
30
30
  setScroll((s) => Math.max(0, s - 1));
31
31
  if (key.downArrow || input === 'j')
32
32
  setScroll((s) => Math.min(maxScroll, s + 1));
33
- if (key.pageUp)
33
+ if (key.pageUp || (key.ctrl && input === 'b'))
34
34
  setScroll((s) => Math.max(0, s - viewport));
35
- if (key.pageDown)
35
+ if (key.pageDown || (key.ctrl && input === 'f'))
36
36
  setScroll((s) => Math.min(maxScroll, s + viewport));
37
+ // Ctrl+U / Ctrl+D — half-page scroll (vim).
38
+ if (key.ctrl && input === 'u')
39
+ setScroll((s) => Math.max(0, s - Math.floor(viewport / 2)));
40
+ if (key.ctrl && input === 'd')
41
+ setScroll((s) => Math.min(maxScroll, s + Math.floor(viewport / 2)));
37
42
  if (input === 'g')
38
43
  setScroll(0);
39
44
  if (input === 'G')
@@ -66,7 +71,7 @@ export function ResponseViewer({ theme, response, isActive, onBack, width, heigh
66
71
  React.createElement(Text, { color: theme.muted }, lines.length > viewport
67
72
  ? `line ${clampedScroll + 1}-${Math.min(clampedScroll + viewport, lines.length)} / ${lines.length}`
68
73
  : `${lines.length} lines`),
69
- React.createElement(Text, { color: theme.muted }, "\u2191\u2193 scroll \u00B7 Tab headers/body \u00B7 g/G top/bottom \u00B7 Esc back"))));
74
+ React.createElement(Text, { color: theme.muted }, "j/k scroll \u00B7 Ctrl+d/u half-page \u00B7 h/l tabs \u00B7 g/G ends \u00B7 q back"))));
70
75
  }
71
76
  function Tabs({ theme, tab, headerCount, }) {
72
77
  const item = (id, label) => (React.createElement(Text, { backgroundColor: tab === id ? theme.selectionBg : undefined, color: tab === id ? theme.accent : theme.fgDim, bold: tab === id },
@@ -1,90 +1,263 @@
1
- import React, { useState } from 'react';
1
+ import React, { useEffect, useReducer } from 'react';
2
2
  import { Box, Text, useInput } from 'ink';
3
+ const WORD = /\w/;
3
4
  /**
4
- * A self-contained text editor supporting single- and multi-line input.
5
- * Enter submits (single-line) or inserts a newline (multi-line).
6
- * Ctrl+S submits, Esc cancels. Arrow keys move the cursor.
5
+ * A modal (vim/nvim-style) text editor supporting single- and multi-line input.
6
+ *
7
+ * INSERT: type text; Enter inserts a newline (multiline) or submits; Esc → NORMAL.
8
+ * NORMAL: h/j/k/l move, w/b/e word motions, 0/$ line ends, gg/G buffer ends,
9
+ * x deletes a char, dd deletes a line, i/a/I/A/o/O enter INSERT, Enter or
10
+ * Ctrl+S saves, Esc cancels.
11
+ *
12
+ * State lives in a reducer so every keystroke is computed atomically from the
13
+ * previous state — this keeps combos (gg, dd) and fast typing correct.
7
14
  */
8
15
  export function TextEditor({ initialValue, multiline = false, masked = false, theme, label, onSubmit, onCancel, }) {
9
- const [value, setValue] = useState(initialValue);
10
- const [cursor, setCursor] = useState(initialValue.length);
16
+ const [state, dispatch] = useReducer(reducer, {
17
+ value: initialValue,
18
+ cursor: initialValue.length,
19
+ mode: 'insert',
20
+ pending: '',
21
+ multiline,
22
+ exit: '',
23
+ });
11
24
  useInput((input, key) => {
12
- if (key.escape) {
13
- onCancel();
14
- return;
15
- }
16
- // Ctrl+S always submits.
17
- if (key.ctrl && input === 's') {
18
- onSubmit(value);
19
- return;
20
- }
21
- if (key.return) {
22
- if (multiline) {
23
- insert('\n');
24
- }
25
- else {
26
- onSubmit(value);
27
- }
28
- return;
29
- }
30
- if (key.leftArrow) {
31
- setCursor((c) => Math.max(0, c - 1));
32
- return;
33
- }
34
- if (key.rightArrow) {
35
- setCursor((c) => Math.min(value.length, c + 1));
36
- return;
37
- }
38
- if (key.upArrow || key.downArrow) {
39
- moveVertical(key.upArrow ? -1 : 1);
25
+ if (state.exit)
40
26
  return;
41
- }
42
- if (key.backspace || key.delete) {
43
- if (cursor > 0) {
44
- setValue((v) => v.slice(0, cursor - 1) + v.slice(cursor));
45
- setCursor((c) => Math.max(0, c - 1));
46
- }
47
- return;
48
- }
49
- // Ignore other control keys (tab, etc.).
50
- if (key.tab || key.ctrl || key.meta)
51
- return;
52
- if (input)
53
- insert(input);
27
+ dispatch({ input, key });
54
28
  });
55
- function insert(text) {
56
- setValue((v) => v.slice(0, cursor) + text + v.slice(cursor));
57
- setCursor((c) => c + text.length);
58
- }
59
- function moveVertical(dir) {
60
- const before = value.slice(0, cursor);
61
- const lines = before.split('\n');
62
- const col = lines[lines.length - 1].length;
63
- const allLines = value.split('\n');
64
- const curLineIdx = lines.length - 1;
65
- const targetIdx = curLineIdx + dir;
66
- if (targetIdx < 0 || targetIdx >= allLines.length)
67
- return;
68
- let offset = 0;
69
- for (let i = 0; i < targetIdx; i++)
70
- offset += allLines[i].length + 1;
71
- offset += Math.min(col, allLines[targetIdx].length);
72
- setCursor(offset);
73
- }
74
- const display = masked ? '•'.repeat(value.length) : value;
75
- // Insert a visible cursor block.
76
- const withCursor = display.slice(0, cursor) +
77
- (cursor < display.length ? `█` : '█') +
78
- display.slice(cursor + (cursor < display.length ? 1 : 0));
29
+ useEffect(() => {
30
+ if (state.exit === 'submit')
31
+ onSubmit(state.value);
32
+ else if (state.exit === 'cancel')
33
+ onCancel();
34
+ // eslint-disable-next-line react-hooks/exhaustive-deps
35
+ }, [state.exit]);
36
+ const display = masked ? '•'.repeat(state.value.length) : state.value;
37
+ const lines = renderWithCursor(display, state.cursor, state.mode);
79
38
  return (React.createElement(Box, { flexDirection: "column" },
80
- React.createElement(Box, null,
39
+ React.createElement(Box, { justifyContent: "space-between" },
81
40
  React.createElement(Text, { color: theme.accent, bold: true },
82
41
  '✎ ',
83
- label)),
84
- React.createElement(Box, { borderStyle: "round", borderColor: theme.borderActive, paddingX: 1, flexDirection: "column", minHeight: multiline ? 6 : 1 },
85
- React.createElement(Text, { color: theme.fg }, withCursor.length ? withCursor : '')),
42
+ label),
43
+ React.createElement(Text, { backgroundColor: state.mode === 'insert' ? theme.success : theme.accentAlt, color: theme.bg, bold: true }, state.mode === 'insert' ? ' INSERT ' : ' NORMAL ')),
44
+ React.createElement(Box, { borderStyle: "round", borderColor: state.mode === 'insert' ? theme.success : theme.borderActive, paddingX: 1, flexDirection: "column", minHeight: multiline ? 6 : 1 }, lines.map((line, i) => (React.createElement(Text, { key: i, color: theme.fg }, line.length ? line : ' ')))),
86
45
  React.createElement(Box, null,
87
- React.createElement(Text, { color: theme.muted }, multiline
88
- ? 'Enter: newline · Ctrl+S: save · Esc: cancel'
89
- : 'Enter: save · Esc: cancel'))));
46
+ React.createElement(Text, { color: theme.muted }, state.mode === 'insert'
47
+ ? multiline
48
+ ? 'type · Enter: newline · Esc: normal · Ctrl+S: save'
49
+ : 'type · Enter: save · Esc: normal · Ctrl+S: save'
50
+ : 'h j k l · w b e · 0 $ · gg G · x dd · i a o · Enter: save · Esc: cancel'))));
51
+ }
52
+ function reducer(s, a) {
53
+ const { input, key } = a;
54
+ // Ctrl+S submits from any mode.
55
+ if (key.ctrl && input === 's')
56
+ return { ...s, exit: 'submit' };
57
+ return s.mode === 'insert' ? insertMode(s, input, key) : normalMode(s, input, key);
58
+ }
59
+ function insertMode(s, input, key) {
60
+ if (key.escape)
61
+ return { ...s, mode: 'normal' };
62
+ if (key.return) {
63
+ if (s.multiline)
64
+ return insertText(s, '\n');
65
+ return { ...s, exit: 'submit' };
66
+ }
67
+ if (key.leftArrow)
68
+ return { ...s, cursor: Math.max(0, s.cursor - 1) };
69
+ if (key.rightArrow)
70
+ return { ...s, cursor: Math.min(s.value.length, s.cursor + 1) };
71
+ if (key.upArrow)
72
+ return moveVertical(s, -1);
73
+ if (key.downArrow)
74
+ return moveVertical(s, 1);
75
+ if (key.backspace || key.delete) {
76
+ if (s.cursor > 0) {
77
+ return {
78
+ ...s,
79
+ value: s.value.slice(0, s.cursor - 1) + s.value.slice(s.cursor),
80
+ cursor: s.cursor - 1,
81
+ };
82
+ }
83
+ return s;
84
+ }
85
+ if (key.tab || key.ctrl || key.meta)
86
+ return s;
87
+ if (input)
88
+ return insertText(s, input);
89
+ return s;
90
+ }
91
+ function normalMode(s, input, key) {
92
+ // Resolve pending two-key operators.
93
+ if (s.pending === 'g') {
94
+ if (input === 'g')
95
+ return { ...s, pending: '', cursor: 0 };
96
+ s = { ...s, pending: '' }; // drop, then process as a fresh key below
97
+ }
98
+ if (s.pending === 'd') {
99
+ if (input === 'd')
100
+ return deleteLine({ ...s, pending: '' });
101
+ s = { ...s, pending: '' };
102
+ }
103
+ if (key.escape)
104
+ return { ...s, exit: 'cancel' };
105
+ if (key.return)
106
+ return { ...s, exit: 'submit' };
107
+ // Motions
108
+ if (input === 'h' || key.leftArrow)
109
+ return { ...s, cursor: Math.max(0, s.cursor - 1) };
110
+ if (input === 'l' || key.rightArrow)
111
+ return { ...s, cursor: Math.min(s.value.length, s.cursor + 1) };
112
+ if (input === 'j' || key.downArrow)
113
+ return moveVertical(s, 1);
114
+ if (input === 'k' || key.upArrow)
115
+ return moveVertical(s, -1);
116
+ if (input === '0')
117
+ return { ...s, cursor: lineStart(s.value, s.cursor) };
118
+ if (input === '$')
119
+ return { ...s, cursor: lineEnd(s.value, s.cursor) };
120
+ if (input === 'w')
121
+ return { ...s, cursor: wordForward(s.value, s.cursor) };
122
+ if (input === 'b')
123
+ return { ...s, cursor: wordBack(s.value, s.cursor) };
124
+ if (input === 'e')
125
+ return { ...s, cursor: wordEnd(s.value, s.cursor) };
126
+ if (input === 'G')
127
+ return { ...s, cursor: lastLineStart(s.value) };
128
+ if (input === 'g')
129
+ return { ...s, pending: 'g' };
130
+ if (input === 'd')
131
+ return { ...s, pending: 'd' };
132
+ // Edits
133
+ if (input === 'x')
134
+ return deleteChar(s);
135
+ if (input === 'i')
136
+ return { ...s, mode: 'insert' };
137
+ if (input === 'a')
138
+ return { ...s, mode: 'insert', cursor: Math.min(s.value.length, s.cursor + 1) };
139
+ if (input === 'I')
140
+ return { ...s, mode: 'insert', cursor: lineStart(s.value, s.cursor) };
141
+ if (input === 'A')
142
+ return { ...s, mode: 'insert', cursor: lineEnd(s.value, s.cursor) };
143
+ if (input === 'o' && s.multiline)
144
+ return openLine(s, 1);
145
+ if (input === 'O' && s.multiline)
146
+ return openLine(s, -1);
147
+ return s;
148
+ }
149
+ // ---- Mutations (pure) ----------------------------------------------------
150
+ function insertText(s, text) {
151
+ return {
152
+ ...s,
153
+ value: s.value.slice(0, s.cursor) + text + s.value.slice(s.cursor),
154
+ cursor: s.cursor + text.length,
155
+ };
156
+ }
157
+ function deleteChar(s) {
158
+ if (s.cursor >= s.value.length) {
159
+ if (s.cursor > 0) {
160
+ return {
161
+ ...s,
162
+ value: s.value.slice(0, s.cursor - 1) + s.value.slice(s.cursor),
163
+ cursor: s.cursor - 1,
164
+ };
165
+ }
166
+ return s;
167
+ }
168
+ return { ...s, value: s.value.slice(0, s.cursor) + s.value.slice(s.cursor + 1) };
169
+ }
170
+ function deleteLine(s) {
171
+ const start = lineStart(s.value, s.cursor);
172
+ let end = lineEnd(s.value, s.cursor);
173
+ if (s.value[end] === '\n')
174
+ end += 1;
175
+ const value = s.value.slice(0, start) + s.value.slice(end);
176
+ return { ...s, value, cursor: Math.min(start, value.length) };
177
+ }
178
+ function openLine(s, dir) {
179
+ if (dir === 1) {
180
+ const end = lineEnd(s.value, s.cursor);
181
+ return { ...s, value: s.value.slice(0, end) + '\n' + s.value.slice(end), cursor: end + 1, mode: 'insert' };
182
+ }
183
+ const start = lineStart(s.value, s.cursor);
184
+ return { ...s, value: s.value.slice(0, start) + '\n' + s.value.slice(start), cursor: start, mode: 'insert' };
185
+ }
186
+ function moveVertical(s, dir) {
187
+ const { value, cursor } = s;
188
+ const col = cursor - lineStart(value, cursor);
189
+ if (dir === -1) {
190
+ const ps = lineStart(value, cursor);
191
+ if (ps === 0)
192
+ return s;
193
+ const prevStart = lineStart(value, ps - 1);
194
+ const prevLen = ps - 1 - prevStart;
195
+ return { ...s, cursor: prevStart + Math.min(col, prevLen) };
196
+ }
197
+ const le = lineEnd(value, cursor);
198
+ if (le >= value.length)
199
+ return s;
200
+ const nextStart = le + 1;
201
+ const nextLen = lineEnd(value, nextStart) - nextStart;
202
+ return { ...s, cursor: nextStart + Math.min(col, nextLen) };
203
+ }
204
+ // ---- Motion helpers (pure) -----------------------------------------------
205
+ function lineStart(v, pos) {
206
+ const nl = v.lastIndexOf('\n', pos - 1);
207
+ return nl === -1 ? 0 : nl + 1;
208
+ }
209
+ function lineEnd(v, pos) {
210
+ const nl = v.indexOf('\n', pos);
211
+ return nl === -1 ? v.length : nl;
212
+ }
213
+ function lastLineStart(v) {
214
+ const nl = v.lastIndexOf('\n');
215
+ return nl === -1 ? 0 : nl + 1;
216
+ }
217
+ function wordForward(v, pos) {
218
+ let i = pos;
219
+ const n = v.length;
220
+ if (i >= n)
221
+ return n;
222
+ const isWord = WORD.test(v[i]);
223
+ while (i < n && !/\s/.test(v[i]) && WORD.test(v[i]) === isWord)
224
+ i++;
225
+ while (i < n && /\s/.test(v[i]))
226
+ i++;
227
+ return i;
228
+ }
229
+ function wordBack(v, pos) {
230
+ let i = pos - 1;
231
+ while (i > 0 && /\s/.test(v[i]))
232
+ i--;
233
+ if (i <= 0)
234
+ return 0;
235
+ const isWord = WORD.test(v[i]);
236
+ while (i > 0 && !/\s/.test(v[i - 1]) && WORD.test(v[i - 1]) === isWord)
237
+ i--;
238
+ return Math.max(0, i);
239
+ }
240
+ function wordEnd(v, pos) {
241
+ let i = pos + 1;
242
+ const n = v.length;
243
+ while (i < n && /\s/.test(v[i]))
244
+ i++;
245
+ if (i >= n)
246
+ return Math.max(0, n - 1);
247
+ const isWord = WORD.test(v[i]);
248
+ while (i + 1 < n && !/\s/.test(v[i + 1]) && WORD.test(v[i + 1]) === isWord)
249
+ i++;
250
+ return i;
251
+ }
252
+ /** Insert a visible cursor glyph and split into display lines. */
253
+ function renderWithCursor(display, cursor, mode) {
254
+ const glyph = mode === 'normal' ? '█' : '▏';
255
+ let out;
256
+ if (mode === 'normal' && cursor < display.length && display[cursor] !== '\n') {
257
+ out = display.slice(0, cursor) + glyph + display.slice(cursor + 1);
258
+ }
259
+ else {
260
+ out = display.slice(0, cursor) + glyph + display.slice(cursor);
261
+ }
262
+ return out.split('\n');
90
263
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@beast01/tcurl",
3
- "version": "1.0.0",
3
+ "version": "1.0.1",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },