@beast01/tcurl 1.0.0 → 1.0.2

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
 
@@ -85,11 +104,17 @@ tcurl --help
85
104
  - **Auth** — Bearer token or HTTP Basic (username / password).
86
105
  - **Redirects** — follow (with method downgrade on 301/302/303) or manual.
87
106
  - **TLS control** — toggle certificate verification for self-signed hosts.
88
- - **Timeouts** — per-request, in milliseconds.
107
+ - **Timeouts & cancellation** — per-request timeout in milliseconds (default
108
+ 30 000; set `0` to disable). While a request is in flight, press `Esc` (or
109
+ `q`) to abort it immediately — even with no timeout set — and `Ctrl+C`
110
+ always quits, so a hung server can never lock you up.
89
111
  - **Pretty responses** — JSON is auto-formatted; scrollable body & headers
90
112
  with status, timing, and size.
91
113
  - **Persistence** — requests are stored as JSON and reused across sessions.
92
114
  - **Export** — copy any saved request as an equivalent `curl` command.
115
+ - **Vim/nvim motions** — `j`/`k`, `gg`/`G`, `h`/`l` navigation throughout, plus
116
+ a fully modal (INSERT / NORMAL) field editor with `w`/`b`/`e`, `0`/`$`,
117
+ `x`, `dd`, `o`/`O`, and `Ctrl+d`/`Ctrl+u` scrolling.
93
118
 
94
119
  ## Where is my data?
95
120
 
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,10 @@ 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);
42
+ // In-flight request: controller to abort it, token to ignore stale results.
43
+ const abortRef = useRef(null);
44
+ const sendToken = useRef(0);
41
45
  const theme = getTheme(store.theme);
42
46
  const requests = store.requests;
43
47
  function persist(next) {
@@ -60,19 +64,41 @@ export function App({ initialStore, exitState, }) {
60
64
  flashMessage('Set a URL first');
61
65
  return;
62
66
  }
67
+ const token = ++sendToken.current;
68
+ const controller = new AbortController();
69
+ abortRef.current = controller;
63
70
  setLoading(true);
64
71
  setResponse(null);
65
- const result = await executeRequest(config);
72
+ const result = await executeRequest(config, controller.signal);
73
+ // Ignore results from a request the user cancelled or superseded.
74
+ if (sendToken.current !== token)
75
+ return;
76
+ abortRef.current = null;
66
77
  setResponse(result);
67
78
  setLoading(false);
68
79
  setMode('response');
69
80
  }
70
- // Global keys — only in list mode and when idle.
81
+ function cancelSend() {
82
+ abortRef.current?.abort();
83
+ abortRef.current = null;
84
+ sendToken.current++; // invalidate the pending result
85
+ setLoading(false);
86
+ flashMessage('Request cancelled');
87
+ }
88
+ // Ctrl+C always quits, from any mode (aborting any in-flight request).
71
89
  useInput((input, key) => {
72
90
  if (key.ctrl && input === 'c') {
91
+ abortRef.current?.abort();
73
92
  exit();
74
- return;
75
93
  }
94
+ });
95
+ // While a request is in flight, Esc (or q) cancels it.
96
+ useInput((input, key) => {
97
+ if (key.escape || input === 'q')
98
+ cancelSend();
99
+ }, { isActive: loading });
100
+ // Global keys — only in list mode and when idle.
101
+ useInput((input, key) => {
76
102
  if (loading)
77
103
  return;
78
104
  if (input === '?') {
@@ -89,6 +115,22 @@ export function App({ initialStore, exitState, }) {
89
115
  persist({ ...store, theme: next });
90
116
  return;
91
117
  }
118
+ // gg → first, G → last (vim).
119
+ if (pendingG.current) {
120
+ pendingG.current = false;
121
+ if (input === 'g') {
122
+ setSelected(0);
123
+ return;
124
+ }
125
+ }
126
+ if (input === 'g') {
127
+ pendingG.current = true;
128
+ return;
129
+ }
130
+ if (input === 'G') {
131
+ setSelected(Math.max(0, requests.length - 1));
132
+ return;
133
+ }
92
134
  if (key.upArrow || input === 'k') {
93
135
  setSelected((s) => Math.max(0, s - 1));
94
136
  return;
@@ -161,8 +203,9 @@ export function App({ initialStore, exitState, }) {
161
203
  : [];
162
204
  return (React.createElement(Box, { flexDirection: "column", width: cols, minHeight: rows - 1 },
163
205
  React.createElement(Header, { theme: theme, subtitle: mode === 'list' ? `${requests.length} saved · ` : '' }),
164
- React.createElement(Box, { flexGrow: 1, flexDirection: "column", marginTop: 1 }, loading ? (React.createElement(Box, { flexGrow: 1, borderStyle: "round", borderColor: theme.border, paddingX: 1, alignItems: "center", justifyContent: "center" },
165
- React.createElement(Spinner, { theme: theme, label: "Sending request\u2026" }))) : mode === 'help' ? (React.createElement(Help, { theme: theme, onClose: () => setMode('list') })) : mode === 'editor' && working ? (React.createElement(RequestEditor, { theme: theme, request: working, isActive: mode === 'editor', onChange: onEditorChange, onSend: onEditorSend, onBack: onEditorBack })) : mode === 'response' && response ? (React.createElement(ResponseViewer, { theme: theme, response: response, isActive: mode === 'response', onBack: () => setMode('list'), width: cols, height: rows })) : (React.createElement(RequestList, { theme: theme, requests: requests, selectedIndex: selected }))),
206
+ React.createElement(Box, { flexGrow: 1, flexDirection: "column", marginTop: 1 }, loading ? (React.createElement(Box, { flexGrow: 1, borderStyle: "round", borderColor: theme.border, paddingX: 1, alignItems: "center", justifyContent: "center", flexDirection: "column" },
207
+ React.createElement(Spinner, { theme: theme, label: "Sending request\u2026" }),
208
+ React.createElement(Text, { color: theme.muted }, "Esc or q to cancel"))) : mode === 'help' ? (React.createElement(Help, { theme: theme, onClose: () => setMode('list') })) : mode === 'editor' && working ? (React.createElement(RequestEditor, { theme: theme, request: working, isActive: mode === 'editor', onChange: onEditorChange, onSend: onEditorSend, onBack: onEditorBack })) : mode === 'response' && response ? (React.createElement(ResponseViewer, { theme: theme, response: response, isActive: mode === 'response', onBack: () => setMode('list'), width: cols, height: rows })) : (React.createElement(RequestList, { theme: theme, requests: requests, selectedIndex: selected }))),
166
209
  React.createElement(Box, { justifyContent: "space-between" },
167
210
  React.createElement(StatusBar, { theme: theme, hints: statusHints }),
168
211
  flash ? (React.createElement(Box, { paddingX: 1 },
@@ -2,12 +2,14 @@ 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'],
12
+ ['Esc / q', 'Cancel a request while it is sending'],
11
13
  ['d', 'Delete request'],
12
14
  ['y', 'Copy as curl (prints on exit)'],
13
15
  ['t', 'Cycle theme (gruvbox / ember)'],
@@ -16,23 +18,38 @@ const SECTIONS = [
16
18
  ],
17
19
  },
18
20
  {
19
- title: 'Editor',
21
+ title: 'Editor (field navigation)',
20
22
  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'],
23
+ ['j / k / Tab', 'Move between fields'],
24
+ ['gg / G', 'First / last field'],
25
+ ['h / l', 'Change method, body type, toggles'],
26
+ ['i / a / Enter', 'Edit the focused field'],
25
27
  ['Esc', 'Back to list (auto-saves)'],
26
28
  ],
27
29
  },
28
30
  {
29
- title: 'Response',
31
+ title: 'Field editor — modal (INSERT / NORMAL)',
30
32
  rows: [
31
- ['↑ / ↓ / j / k', 'Scroll'],
32
- ['PgUp / PgDn', 'Page scroll'],
33
+ ['Esc', 'INSERT → NORMAL'],
34
+ ['i a I A o O', 'NORMAL → INSERT (various)'],
35
+ ['h j k l', 'Move by char / line'],
36
+ ['w / b / e', 'Word forward / back / end'],
37
+ ['0 / $', 'Line start / end'],
38
+ ['gg / G', 'Buffer start / end'],
39
+ ['x / dd', 'Delete char / line'],
40
+ ['Enter / Ctrl+S', 'Save field'],
41
+ ['Esc (in NORMAL)', 'Cancel edit'],
42
+ ],
43
+ },
44
+ {
45
+ title: 'Response (vim keys)',
46
+ rows: [
47
+ ['j / k', 'Scroll down / up'],
48
+ ['Ctrl+d / Ctrl+u', 'Half-page down / up'],
49
+ ['Ctrl+f / Ctrl+b', 'Full-page down / up'],
33
50
  ['g / G', 'Top / bottom'],
34
- ['Tab / h', 'Switch body / headers'],
35
- ['Esc', 'Back'],
51
+ ['h / l / Tab', 'Switch body / headers'],
52
+ ['q / Esc', 'Back'],
36
53
  ],
37
54
  },
38
55
  ];
@@ -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
  }
@@ -141,14 +141,28 @@ function doRequest(opts, redirectsLeft) {
141
141
  req.end();
142
142
  });
143
143
  }
144
- /** Execute a request config and return a normalized result (never throws). */
145
- export async function executeRequest(config) {
144
+ /**
145
+ * Execute a request config and return a normalized result (never throws).
146
+ *
147
+ * The optional `externalSignal` lets the caller (the UI) cancel an in-flight
148
+ * request; when it fires the result carries `error: "Request cancelled"`.
149
+ * A per-request timeout aborts independently.
150
+ */
151
+ export async function executeRequest(config, externalSignal) {
146
152
  const start = Date.now();
147
153
  const controller = new AbortController();
148
154
  let timer;
149
155
  if (config.timeoutMs > 0) {
150
156
  timer = setTimeout(() => controller.abort(), config.timeoutMs);
151
157
  }
158
+ // Bridge an external (user) cancellation into our controller.
159
+ const onExternalAbort = () => controller.abort();
160
+ if (externalSignal) {
161
+ if (externalSignal.aborted)
162
+ controller.abort();
163
+ else
164
+ externalSignal.addEventListener('abort', onExternalAbort, { once: true });
165
+ }
152
166
  try {
153
167
  const url = buildUrl(config);
154
168
  if (url.protocol !== 'http:' && url.protocol !== 'https:') {
@@ -168,6 +182,7 @@ export async function executeRequest(config) {
168
182
  }
169
183
  catch (err) {
170
184
  const aborted = err?.name === 'AbortError' || controller.signal.aborted;
185
+ const cancelled = externalSignal?.aborted ?? false;
171
186
  return {
172
187
  ok: false,
173
188
  status: 0,
@@ -178,13 +193,17 @@ export async function executeRequest(config) {
178
193
  durationMs: Date.now() - start,
179
194
  sizeBytes: 0,
180
195
  finalUrl: config.url,
181
- error: aborted
182
- ? `Request timed out after ${config.timeoutMs}ms`
183
- : String(err?.message ?? err),
196
+ error: cancelled
197
+ ? 'Request cancelled'
198
+ : aborted
199
+ ? `Request timed out after ${config.timeoutMs}ms`
200
+ : String(err?.message ?? err),
184
201
  };
185
202
  }
186
203
  finally {
187
204
  if (timer)
188
205
  clearTimeout(timer);
206
+ if (externalSignal)
207
+ externalSignal.removeEventListener('abort', onExternalAbort);
189
208
  }
190
209
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@beast01/tcurl",
3
- "version": "1.0.0",
3
+ "version": "1.0.2",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },