@aragon-agent/cli 0.6.11 → 0.6.13

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 (115) hide show
  1. package/CHANGELOG.md +10 -1
  2. package/README.md +97 -20
  3. package/dist/agent/controller.d.ts +38 -3
  4. package/dist/agent/controller.js +151 -60
  5. package/dist/agent/model-profile-settings.d.ts +31 -0
  6. package/dist/agent/model-profile-settings.js +199 -0
  7. package/dist/agent/queued-messages.d.ts +20 -0
  8. package/dist/agent/queued-messages.js +46 -0
  9. package/dist/agent/reducer.d.ts +21 -2
  10. package/dist/agent/reducer.js +65 -37
  11. package/dist/cli.js +15 -1
  12. package/dist/commands/builtins.js +83 -42
  13. package/dist/commands/queue-command.d.ts +3 -0
  14. package/dist/commands/queue-command.js +5 -0
  15. package/dist/commands/registry.d.ts +3 -0
  16. package/dist/compaction/child.d.ts +4 -2
  17. package/dist/compaction/child.js +4 -1
  18. package/dist/compaction/compactor.d.ts +7 -2
  19. package/dist/compaction/compactor.js +13 -11
  20. package/dist/compaction/context-command.js +5 -0
  21. package/dist/compaction/wiring.d.ts +4 -3
  22. package/dist/compaction/wiring.js +6 -1
  23. package/dist/config/cli-commands.d.ts +2 -0
  24. package/dist/config/cli-commands.js +16 -4
  25. package/dist/config/load.d.ts +2 -1
  26. package/dist/config/load.js +15 -8
  27. package/dist/config/model-profile-resolution.d.ts +20 -0
  28. package/dist/config/model-profile-resolution.js +81 -0
  29. package/dist/config/model-profile-store.d.ts +67 -0
  30. package/dist/config/model-profile-store.js +153 -0
  31. package/dist/config/model-profiles.d.ts +44 -0
  32. package/dist/config/model-profiles.js +121 -0
  33. package/dist/config/schema.d.ts +9 -0
  34. package/dist/config/store.d.ts +11 -1
  35. package/dist/config/store.js +43 -27
  36. package/dist/fast/resolve.d.ts +2 -1
  37. package/dist/fast/resolve.js +6 -4
  38. package/dist/fast/wiring.d.ts +5 -3
  39. package/dist/fast/wiring.js +8 -4
  40. package/dist/input/enter-sequences.d.ts +11 -10
  41. package/dist/input/enter-sequences.js +29 -27
  42. package/dist/input/stdin-filter.js +93 -28
  43. package/dist/logging/install.d.ts +3 -0
  44. package/dist/logging/install.js +4 -2
  45. package/dist/logging/secret-registry.d.ts +2 -0
  46. package/dist/logging/secret-registry.js +18 -0
  47. package/dist/session/persist.d.ts +6 -7
  48. package/dist/session/persist.js +6 -9
  49. package/dist/session/store.d.ts +4 -1
  50. package/dist/session/validate-session.d.ts +3 -0
  51. package/dist/session/validate-session.js +207 -0
  52. package/dist/team/runtime.d.ts +2 -1
  53. package/dist/team/subagent.d.ts +3 -1
  54. package/dist/team/subagent.js +3 -1
  55. package/dist/ui/ActivityLine.d.ts +6 -70
  56. package/dist/ui/ActivityLine.js +12 -34
  57. package/dist/ui/App.d.ts +1 -0
  58. package/dist/ui/App.js +161 -226
  59. package/dist/ui/BottomStatusRow.d.ts +6 -86
  60. package/dist/ui/BottomStatusRow.js +11 -26
  61. package/dist/ui/Composer.d.ts +18 -84
  62. package/dist/ui/Composer.js +9 -100
  63. package/dist/ui/PromptInput.d.ts +6 -2
  64. package/dist/ui/PromptInput.js +91 -105
  65. package/dist/ui/QueuePanel.d.ts +8 -0
  66. package/dist/ui/QueuePanel.js +9 -0
  67. package/dist/ui/QueueStatusRow.d.ts +24 -0
  68. package/dist/ui/QueueStatusRow.js +51 -0
  69. package/dist/ui/StatusBar.d.ts +15 -149
  70. package/dist/ui/StatusBar.js +27 -112
  71. package/dist/ui/clipboard-task.d.ts +29 -0
  72. package/dist/ui/clipboard-task.js +66 -0
  73. package/dist/ui/clipboard.d.ts +29 -50
  74. package/dist/ui/clipboard.js +141 -75
  75. package/dist/ui/composer-input.d.ts +28 -0
  76. package/dist/ui/composer-input.js +46 -0
  77. package/dist/ui/composer-limits.d.ts +3 -4
  78. package/dist/ui/composer-limits.js +4 -9
  79. package/dist/ui/composer-rows.d.ts +1 -1
  80. package/dist/ui/composer-rows.js +24 -137
  81. package/dist/ui/editor-navigation.d.ts +45 -0
  82. package/dist/ui/editor-navigation.js +85 -0
  83. package/dist/ui/editor-reducer.d.ts +9 -0
  84. package/dist/ui/editor-reducer.js +31 -15
  85. package/dist/ui/enter-frames.d.ts +15 -31
  86. package/dist/ui/enter-frames.js +14 -33
  87. package/dist/ui/interaction-copy.d.ts +77 -0
  88. package/dist/ui/interaction-copy.js +40 -0
  89. package/dist/ui/interaction-hints.d.ts +18 -0
  90. package/dist/ui/interaction-hints.js +92 -0
  91. package/dist/ui/layout/budget.js +1 -1
  92. package/dist/ui/layout/queue-layout.d.ts +19 -0
  93. package/dist/ui/layout/queue-layout.js +26 -0
  94. package/dist/ui/layout/status-layout.d.ts +55 -0
  95. package/dist/ui/layout/status-layout.js +109 -0
  96. package/dist/ui/layout/todo-layout.d.ts +1 -0
  97. package/dist/ui/layout/todo-layout.js +1 -1
  98. package/dist/ui/overlays/HelpOverlay.js +11 -3
  99. package/dist/ui/overlays/ModelProfileEditor.d.ts +12 -0
  100. package/dist/ui/overlays/ModelProfileEditor.js +103 -0
  101. package/dist/ui/overlays/ModelProfilePicker.d.ts +35 -0
  102. package/dist/ui/overlays/ModelProfilePicker.js +167 -0
  103. package/dist/ui/overlays/QueueOverlay.d.ts +22 -0
  104. package/dist/ui/overlays/QueueOverlay.js +79 -0
  105. package/dist/ui/overlays/SettingsScreen.d.ts +9 -2
  106. package/dist/ui/overlays/SettingsScreen.js +347 -3
  107. package/dist/ui/overlays/model-profile-state.d.ts +74 -0
  108. package/dist/ui/overlays/model-profile-state.js +119 -0
  109. package/dist/ui/run-status-row.d.ts +7 -19
  110. package/dist/ui/run-status-row.js +2 -18
  111. package/dist/ui/selection/selection-controller.d.ts +2 -2
  112. package/dist/ui/selection/selection-controller.js +58 -20
  113. package/dist/ui/transcript-text.js +20 -12
  114. package/dist/ui/use-wheel-routing.js +2 -1
  115. package/package.json +2 -2
@@ -22,10 +22,9 @@ export declare const USER_ENTRY_MAX_ROWS = 40;
22
22
  /**
23
23
  * Ceiling on the composer's DRAFT rows at a given terminal height.
24
24
  *
25
- * Non-decreasing in `terminalRows`. Returning `Infinity` reduces the whole
26
- * height-bound layer to the rung below it (section 12, S4), which is what makes
27
- * that stage revertible on its own.
25
+ * Non-decreasing in `terminalRows`, with one quarter of the terminal available
26
+ * to the draft and an absolute six-row ceiling shared by rendering and budgets.
28
27
  */
29
28
  export declare function draftMaxRows(terminalRows: number): number;
30
- /** The terminal height at which `draftMaxRows` steps from 3 to 6. Pinned by test. */
29
+ /** Compatibility export for the shared hint visibility threshold. */
31
30
  export declare const DRAFT_MAX_ROWS_HINT_TIER = 20;
@@ -33,18 +33,13 @@ const DRAFT_TIER_MIN_ROWS = 20;
33
33
  /**
34
34
  * Ceiling on the composer's DRAFT rows at a given terminal height.
35
35
  *
36
- * Non-decreasing in `terminalRows`. Returning `Infinity` reduces the whole
37
- * height-bound layer to the rung below it (section 12, S4), which is what makes
38
- * that stage revertible on its own.
36
+ * Non-decreasing in `terminalRows`, with one quarter of the terminal available
37
+ * to the draft and an absolute six-row ceiling shared by rendering and budgets.
39
38
  */
40
39
  export function draftMaxRows(terminalRows) {
41
40
  if (!Number.isFinite(terminalRows))
42
41
  return 3;
43
- if (terminalRows >= 30)
44
- return 10;
45
- if (terminalRows >= DRAFT_TIER_MIN_ROWS)
46
- return 6;
47
- return 3;
42
+ return Math.max(1, Math.min(6, Math.floor(terminalRows / 4)));
48
43
  }
49
- /** The terminal height at which `draftMaxRows` steps from 3 to 6. Pinned by test. */
44
+ /** Compatibility export for the shared hint visibility threshold. */
50
45
  export const DRAFT_MAX_ROWS_HINT_TIER = DRAFT_TIER_MIN_ROWS;
@@ -44,7 +44,7 @@ export interface ComposerLayoutInput {
44
44
  readonly maxRows: number;
45
45
  readonly active: boolean;
46
46
  }
47
- /** Total display width of a run, using the same table the layout used. */
47
+ /** Width of a displayed run, measured by complete graphemes. */
48
48
  export declare function displayWidth(text: string): number;
49
49
  /**
50
50
  * Wrap `buffer` into rows, locate the caret, and select a window of at most
@@ -18,65 +18,11 @@
18
18
  * spaces, so it destroys indentation and makes the character-index-to-screen-cell
19
19
  * mapping the caret depends on impossible to state, let alone compute.
20
20
  */
21
- import stringWidth from 'string-width';
21
+ import { buildEditorVisualMap, graphemes, graphemeWidth } from './editor-navigation.js';
22
22
  import { tokenSpans } from './paste-tokens.js';
23
- /**
24
- * Columns a TAB advances to.
25
- *
26
- * TABS ARE EXPANDED TO SPACES FOR DISPLAY ONLY -- the buffer keeps the tab, so
27
- * the model receives exactly what was pasted. The alternative is to emit the raw
28
- * tab and let the terminal expand it, which puts the terminal's tab stop (8) and
29
- * this module's column arithmetic in disagreement: the row would overflow its box
30
- * and the terminal would wrap it a second time, which is the corruption the whole
31
- * feature exists to remove.
32
- */
33
- const TAB_WIDTH = 4;
34
- /**
35
- * Display width of one code point.
36
- *
37
- * The ASCII fast path matters: `layoutComposer` runs on every keystroke and a
38
- * recalled prompt-history entry can be thousands of characters, so one
39
- * `string-width` call per character would be the cost this package spent
40
- * `tui-render-performance` removing.
41
- */
42
- function charWidth(ch) {
43
- const code = ch.codePointAt(0) ?? 0;
44
- if (code >= 0x20 && code < 0x7f)
45
- return 1;
46
- return stringWidth(ch);
47
- }
48
- /** Total display width of a run, using the same table the layout used. */
23
+ /** Width of a displayed run, measured by complete graphemes. */
49
24
  export function displayWidth(text) {
50
- let w = 0;
51
- for (const ch of text)
52
- w += charWidth(ch);
53
- return w;
54
- }
55
- /** Normalize only the visual projection; the editor keeps its UTF-16 index. */
56
- function visualCursor(buffer, cursor) {
57
- let index = Math.max(0, Math.min(buffer.length, cursor));
58
- const code = buffer.charCodeAt(index);
59
- const previous = buffer.charCodeAt(index - 1);
60
- if (code >= 0xdc00 && code <= 0xdfff && previous >= 0xd800 && previous <= 0xdbff)
61
- index -= 1;
62
- if (index === buffer.length || buffer[index] === '\n')
63
- return index;
64
- const ch = String.fromCodePoint(buffer.codePointAt(index));
65
- if (ch === '\t' || charWidth(ch) > 0)
66
- return index;
67
- const start = buffer.lastIndexOf('\n', index - 1) + 1;
68
- let nearest = -1;
69
- let i = start;
70
- while (i < buffer.length && buffer[i] !== '\n') {
71
- const point = String.fromCodePoint(buffer.codePointAt(i));
72
- if (point === '\t' || charWidth(point) > 0) {
73
- if (i > index)
74
- return nearest >= 0 ? nearest : i;
75
- nearest = i;
76
- }
77
- i += point.length;
78
- }
79
- return nearest >= 0 ? nearest : i;
25
+ return graphemes(text).reduce((width, part) => width + graphemeWidth(part.text), 0);
80
26
  }
81
27
  /**
82
28
  * Wrap `buffer` into rows, locate the caret, and select a window of at most
@@ -89,88 +35,29 @@ function visualCursor(buffer, cursor) {
89
35
  */
90
36
  export function layoutComposer(input) {
91
37
  const { buffer, active } = input;
92
- const cursor = visualCursor(buffer, input.cursor);
93
- const cols = Math.max(1, Math.floor(input.cols));
94
38
  const maxRows = input.maxRows >= 1 ? Math.floor(input.maxRows) : 1;
39
+ const map = buildEditorVisualMap(input);
40
+ const cursorRowAbs = active ? map.cursor.row : -1;
41
+ const cursorCol = map.cursor.column;
95
42
  const spans = tokenSpans(buffer);
96
43
  let spanIndex = 0;
97
- /** Monotonic in `index`, which is why the pointer is safe. */
98
- const kindAt = (index) => {
99
- while (spanIndex < spans.length && spans[spanIndex].end <= index)
100
- spanIndex += 1;
101
- const span = spans[spanIndex];
102
- return span && index >= span.start && index < span.end ? 'token' : 'text';
103
- };
104
- const rows = [];
105
- let segments = [];
106
- let col = 0;
107
- let cursorRowAbs = -1;
108
- let cursorCol = 0;
109
- const breakRow = () => {
110
- rows.push({ segments });
111
- segments = [];
112
- col = 0;
113
- };
114
- const append = (text, width, kind) => {
115
- const last = segments[segments.length - 1];
116
- if (last && last.kind === kind)
117
- segments[segments.length - 1] = { text: last.text + text, kind };
118
- else
119
- segments.push({ text, kind });
120
- col += width;
121
- };
122
- const ensureRoom = (width) => {
123
- if (col > 0 && col + width > cols)
124
- breakRow();
125
- };
126
- const length = buffer.length;
127
- let i = 0;
128
- while (i <= length) {
129
- const atCursor = active && i === cursor && cursorRowAbs === -1;
130
- if (i === length) {
131
- if (atCursor) {
132
- ensureRoom(1);
133
- cursorRowAbs = rows.length;
134
- cursorCol = col;
135
- }
136
- break;
137
- }
138
- const ch = String.fromCodePoint(buffer.codePointAt(i));
139
- if (ch === '\n') {
140
- if (atCursor) {
141
- ensureRoom(1);
142
- cursorRowAbs = rows.length;
143
- cursorCol = col;
144
- }
145
- breakRow();
146
- i += 1;
147
- continue;
148
- }
149
- const kind = kindAt(i);
150
- if (ch === '\t') {
151
- let advance = TAB_WIDTH - (col % TAB_WIDTH);
152
- if (col > 0 && col + advance > cols) {
153
- breakRow();
154
- advance = TAB_WIDTH;
155
- }
156
- if (atCursor) {
157
- cursorRowAbs = rows.length;
158
- cursorCol = col;
159
- }
160
- append(' '.repeat(advance), advance, kind);
161
- i += 1;
162
- continue;
163
- }
164
- const width = charWidth(ch);
165
- ensureRoom(width);
166
- if (atCursor) {
167
- cursorRowAbs = rows.length;
168
- cursorCol = col;
44
+ const rows = map.rows.map((cells) => {
45
+ const segments = [];
46
+ for (const cell of cells) {
47
+ while (spanIndex < spans.length && spans[spanIndex].end <= cell.index)
48
+ spanIndex += 1;
49
+ const span = spans[spanIndex];
50
+ const kind = span && cell.index >= span.start && cell.index < span.end ? 'token' : 'text';
51
+ const last = segments[segments.length - 1];
52
+ if (last && last.kind === kind)
53
+ segments[segments.length - 1] = {
54
+ text: last.text + cell.text, kind,
55
+ };
56
+ else
57
+ segments.push({ text: cell.text, kind });
169
58
  }
170
- append(ch, width, kind);
171
- i += ch.length;
172
- }
173
- rows.push({ segments });
59
+ return { segments };
60
+ });
174
61
  const totalRows = rows.length;
175
62
  const cap = Math.max(1, Math.min(maxRows, totalRows));
176
63
  let start = Math.max(0, totalRows - cap);
@@ -213,8 +100,8 @@ export function splitRowAtColumn(row, column) {
213
100
  const cells = [];
214
101
  let leading = '';
215
102
  for (const segment of row.segments) {
216
- for (const ch of segment.text) {
217
- if (charWidth(ch) === 0) {
103
+ for (const { text: ch } of graphemes(segment.text)) {
104
+ if (graphemeWidth(ch) === 0) {
218
105
  const last = cells[cells.length - 1];
219
106
  if (last)
220
107
  cells[cells.length - 1] = { ...last, text: last.text + ch };
@@ -0,0 +1,45 @@
1
+ export interface EditorGrapheme {
2
+ readonly text: string;
3
+ readonly index: number;
4
+ readonly end: number;
5
+ }
6
+ /** Segment text without changing its UTF-16 offsets or stored whitespace. */
7
+ export declare function graphemes(text: string): EditorGrapheme[];
8
+ /** Snap an arbitrary UTF-16 offset to a complete grapheme boundary. */
9
+ export declare function snapGrapheme(text: string, cursor: number, right?: boolean): number;
10
+ /** Move one complete grapheme, leaving the two buffer edges unchanged. */
11
+ export declare function stepGrapheme(text: string, cursor: number, direction: 'left' | 'right'): number;
12
+ /** Width of one grapheme (ASCII avoids the Unicode width-table lookup). */
13
+ export declare function graphemeWidth(text: string): number;
14
+ export interface VisualCell extends EditorGrapheme {
15
+ readonly column: number;
16
+ readonly width: number;
17
+ }
18
+ export interface VisualPosition {
19
+ readonly index: number;
20
+ readonly row: number;
21
+ readonly column: number;
22
+ }
23
+ export interface EditorVisualMap {
24
+ readonly rows: VisualCell[][];
25
+ readonly positions: VisualPosition[];
26
+ readonly cursor: VisualPosition;
27
+ }
28
+ /** Map graphemes to hard-wrapped rows, sharing tab stops and caret reservation. */
29
+ export declare function buildEditorVisualMap(input: {
30
+ buffer: string;
31
+ cols: number;
32
+ cursor: number;
33
+ active?: boolean;
34
+ }): EditorVisualMap;
35
+ /** Move to the closest boundary at or left of the preferred display column. */
36
+ export declare function moveVisualCursor(input: {
37
+ buffer: string;
38
+ cursor: number;
39
+ cols: number;
40
+ direction: 'up' | 'down';
41
+ preferredVisualColumn?: number;
42
+ }): {
43
+ cursor: number;
44
+ preferredVisualColumn: number;
45
+ } | null;
@@ -0,0 +1,85 @@
1
+ /** Shared UTF-16 grapheme boundaries and terminal-cell layout for the editor. */
2
+ import stringWidth from 'string-width';
3
+ const segmenter = new Intl.Segmenter(undefined, { granularity: 'grapheme' });
4
+ /** Segment text without changing its UTF-16 offsets or stored whitespace. */
5
+ export function graphemes(text) {
6
+ return Array.from(segmenter.segment(text), ({ segment, index }) => ({
7
+ text: segment, index, end: index + segment.length,
8
+ }));
9
+ }
10
+ /** Snap an arbitrary UTF-16 offset to a complete grapheme boundary. */
11
+ export function snapGrapheme(text, cursor, right = false) {
12
+ const clamped = Math.max(0, Math.min(text.length, cursor));
13
+ for (const part of segmenter.segment(text)) {
14
+ const end = part.index + part.segment.length;
15
+ if (clamped > part.index && clamped < end)
16
+ return right ? end : part.index;
17
+ if (part.index >= clamped)
18
+ break;
19
+ }
20
+ return clamped;
21
+ }
22
+ /** Move one complete grapheme, leaving the two buffer edges unchanged. */
23
+ export function stepGrapheme(text, cursor, direction) {
24
+ if (direction === 'left')
25
+ return snapGrapheme(text, Math.max(0, cursor - 1));
26
+ return snapGrapheme(text, Math.min(text.length, cursor + 1), true);
27
+ }
28
+ /** Width of one grapheme (ASCII avoids the Unicode width-table lookup). */
29
+ export function graphemeWidth(text) {
30
+ if (text.length === 1 && text >= ' ' && text <= '~')
31
+ return 1;
32
+ return stringWidth(text);
33
+ }
34
+ /** Map graphemes to hard-wrapped rows, sharing tab stops and caret reservation. */
35
+ export function buildEditorVisualMap(input) {
36
+ const cols = Math.max(1, Math.floor(input.cols));
37
+ const cursor = snapGrapheme(input.buffer, input.cursor);
38
+ const rows = [[]];
39
+ const positions = [];
40
+ let column = 0;
41
+ const breakRow = () => { rows.push([]); column = 0; };
42
+ const locate = (index) => {
43
+ positions.push({ index, row: rows.length - 1, column });
44
+ };
45
+ for (const part of graphemes(input.buffer)) {
46
+ if (part.text === '\n') {
47
+ if (input.active !== false && part.index === cursor && column >= cols)
48
+ breakRow();
49
+ locate(part.index);
50
+ breakRow();
51
+ continue;
52
+ }
53
+ let width = part.text === '\t' ? Math.min(cols, 4 - column % 4)
54
+ : graphemeWidth(part.text);
55
+ if (column > 0 && column + width > cols) {
56
+ breakRow();
57
+ if (part.text === '\t')
58
+ width = Math.min(cols, 4);
59
+ }
60
+ locate(part.index);
61
+ rows[rows.length - 1].push({ ...part,
62
+ text: part.text === '\t' ? ' '.repeat(width) : part.text, column, width });
63
+ column += width;
64
+ }
65
+ if (input.active !== false && cursor === input.buffer.length && column >= cols)
66
+ breakRow();
67
+ locate(input.buffer.length);
68
+ const caret = positions.find((position) => position.index === cursor);
69
+ return { rows, positions, cursor: caret };
70
+ }
71
+ /** Move to the closest boundary at or left of the preferred display column. */
72
+ export function moveVisualCursor(input) {
73
+ const map = buildEditorVisualMap(input);
74
+ const row = map.cursor.row + (input.direction === 'up' ? -1 : 1);
75
+ if (row < 0 || row >= map.rows.length)
76
+ return null;
77
+ const preferredVisualColumn = input.preferredVisualColumn ?? map.cursor.column;
78
+ const candidates = map.positions.filter((position) => position.row === row);
79
+ let target = candidates[0];
80
+ for (const position of candidates) {
81
+ if (position.column <= preferredVisualColumn)
82
+ target = position;
83
+ }
84
+ return target ? { cursor: target.index, preferredVisualColumn } : null;
85
+ }
@@ -26,6 +26,7 @@ import { type PasteRecord } from './paste-tokens.js';
26
26
  export interface EditorState {
27
27
  buffer: string;
28
28
  cursor: number;
29
+ preferredVisualColumn?: number;
29
30
  historyIndex: number | null;
30
31
  dismissed: boolean;
31
32
  sel: number;
@@ -70,9 +71,12 @@ export type EditorAction = {
70
71
  cursor: number;
71
72
  } | {
72
73
  type: 'backspace';
74
+ } | {
75
+ type: 'delete';
73
76
  } | {
74
77
  type: 'moveCursor';
75
78
  cursor: number;
79
+ preferredVisualColumn?: number;
76
80
  } | {
77
81
  type: 'recall';
78
82
  buffer: string;
@@ -86,6 +90,11 @@ export type EditorAction = {
86
90
  } | {
87
91
  type: 'clear';
88
92
  }
93
+ /** Only local reducer-planned states may be adopted; prune token payloads again. */
94
+ | {
95
+ type: 'adopt';
96
+ state: EditorState;
97
+ }
89
98
  /**
90
99
  * ONE chunk of stdin, already split into ordered runs. The only new action,
91
100
  * and it is what keeps the one-dispatch-per-key rule true for a chunk that
@@ -23,6 +23,7 @@
23
23
  * Extracted from the component so it is unit-testable without mounting Ink.
24
24
  */
25
25
  import { expandRangeOverTokens, formatPasteToken, makePasteRecord, referencedIds, shouldCollapse, snapOutOfToken, } from './paste-tokens.js';
26
+ import { snapGrapheme, stepGrapheme } from './editor-navigation.js';
26
27
  const NO_PASTES = new Map();
27
28
  export const INITIAL_EDITOR_STATE = {
28
29
  buffer: '',
@@ -42,7 +43,9 @@ export const INITIAL_EDITOR_STATE = {
42
43
  * typing again, so suggestions should come back), and re-homes the selection
43
44
  * (the candidate list is about to be recomputed).
44
45
  */
45
- const DRAFT_FLAGS = { historyIndex: null, dismissed: false, sel: 0 };
46
+ const DRAFT_FLAGS = {
47
+ historyIndex: null, dismissed: false, sel: 0, preferredVisualColumn: undefined,
48
+ };
46
49
  /**
47
50
  * Drop payloads the buffer no longer references (I-7).
48
51
  *
@@ -82,15 +85,18 @@ function withPrune(next) {
82
85
  }
83
86
  export function editorReducer(state, action) {
84
87
  switch (action.type) {
88
+ case 'adopt':
89
+ return withPrune(action.state);
85
90
  case 'insert': {
86
91
  // D-8: typing can never land INSIDE a token. A no-op on a buffer with no
87
92
  // tokens, which is every buffer until the first collapse.
88
- const cursor = snapOutOfToken(state.buffer, state.cursor);
93
+ const cursor = snapOutOfToken(state.buffer, snapGrapheme(state.buffer, state.cursor));
94
+ const buffer = state.buffer.slice(0, cursor) + action.text + state.buffer.slice(cursor);
89
95
  return withPrune({
90
96
  ...state,
91
97
  ...DRAFT_FLAGS,
92
- buffer: state.buffer.slice(0, cursor) + action.text + state.buffer.slice(cursor),
93
- cursor: cursor + action.text.length,
98
+ buffer,
99
+ cursor: snapGrapheme(buffer, cursor + action.text.length, true),
94
100
  });
95
101
  }
96
102
  case 'replace':
@@ -98,24 +104,29 @@ export function editorReducer(state, action) {
98
104
  ...state,
99
105
  ...DRAFT_FLAGS,
100
106
  buffer: action.buffer,
101
- cursor: action.cursor,
107
+ cursor: snapGrapheme(action.buffer, action.cursor),
102
108
  });
103
- case 'backspace': {
104
- if (state.cursor <= 0)
109
+ case 'backspace':
110
+ case 'delete': {
111
+ const cursor = snapGrapheme(state.buffer, state.cursor);
112
+ const backwards = action.type === 'backspace';
113
+ if (backwards ? cursor <= 0 : cursor >= state.buffer.length)
105
114
  return state;
106
115
  // G5: one `Backspace` immediately after `...lines]` removes the whole
107
116
  // token and releases its payload, in ONE dispatch.
108
- const range = expandRangeOverTokens(state.buffer, state.cursor - 1, state.cursor);
117
+ const other = stepGrapheme(state.buffer, cursor, backwards ? 'left' : 'right');
118
+ const range = expandRangeOverTokens(state.buffer, backwards ? other : cursor, backwards ? cursor : other);
119
+ const buffer = state.buffer.slice(0, range.from) + state.buffer.slice(range.to);
109
120
  return withPrune({
110
121
  ...state,
111
122
  ...DRAFT_FLAGS,
112
- buffer: state.buffer.slice(0, range.from) + state.buffer.slice(range.to),
113
- cursor: range.from,
123
+ buffer,
124
+ cursor: snapGrapheme(buffer, range.from),
114
125
  });
115
126
  }
116
127
  case 'input': {
117
128
  let buffer = state.buffer;
118
- let cursor = snapOutOfToken(buffer, state.cursor);
129
+ let cursor = snapOutOfToken(buffer, snapGrapheme(buffer, state.cursor));
119
130
  let pastes = null;
120
131
  for (const segment of action.segments) {
121
132
  const collapse = segment.kind === 'paste' && shouldCollapse(segment.text);
@@ -127,7 +138,7 @@ export function editorReducer(state, action) {
127
138
  pastes.set(record.id, record);
128
139
  }
129
140
  buffer = buffer.slice(0, cursor) + text + buffer.slice(cursor);
130
- cursor += text.length;
141
+ cursor = snapGrapheme(buffer, cursor + text.length, true);
131
142
  }
132
143
  return withPrune({
133
144
  ...state,
@@ -137,11 +148,15 @@ export function editorReducer(state, action) {
137
148
  ...(pastes ? { pastes } : {}),
138
149
  });
139
150
  }
140
- case 'moveCursor':
151
+ case 'moveCursor': {
141
152
  // NO DRAFT-FLAG RESET. Moving the caret is not an edit: clearing
142
153
  // `historyIndex` here would end a history walk the moment the user pressed
143
154
  // ← to look at what they recalled.
144
- return state.cursor === action.cursor ? state : { ...state, cursor: action.cursor };
155
+ const cursor = snapGrapheme(state.buffer, action.cursor);
156
+ const preferredVisualColumn = action.preferredVisualColumn;
157
+ return state.cursor === cursor && state.preferredVisualColumn === preferredVisualColumn
158
+ ? state : { ...state, cursor, preferredVisualColumn };
159
+ }
145
160
  case 'recall':
146
161
  // `historyIndex` is SET, not reset, which is why recall cannot reuse
147
162
  // `replace` — the two differ in exactly the field that keeps the walk
@@ -153,7 +168,8 @@ export function editorReducer(state, action) {
153
168
  return withPrune({
154
169
  ...state,
155
170
  buffer: action.buffer,
156
- cursor: action.cursor,
171
+ cursor: snapGrapheme(action.buffer, action.cursor),
172
+ preferredVisualColumn: undefined,
157
173
  historyIndex: action.historyIndex,
158
174
  });
159
175
  case 'select':
@@ -1,37 +1,21 @@
1
- /**
2
- * Reading Enter frames back out of an Ink `input` string
3
- * (tui-shift-enter-copy-queue 3.5) -- pure.
4
- *
5
- * The stdin filter rewrites a modified Enter into `ENTER_NEWLINE_FRAME` inline,
6
- * in stream order, exactly the way it frames a paste. This module is the only
7
- * place that takes those frames apart again, and it handles BOTH newline
8
- * sources the composer can see in one `input` string:
9
- *
10
- * - `ENTER_NEWLINE_FRAME` -- Shift+Enter / Alt+Enter / CSI-u Enter, translated
11
- * by the filter; and
12
- * - a bare `\n` -- Ctrl+J, which Ink parses to `name: 'enter'` with
13
- * `input === '\n'` and NO `key.return`, so it reaches this branch as an
14
- * ordinary (control) character that must still mean "newline".
15
- *
16
- * I-12, THE BROADCAST OBLIGATION, APPLIES TO THESE FRAMES TOO. `useInput`
17
- * re-emits every chunk to every mounted handler, `stripPasteFrames` does not
18
- * know this frame, and a NUL appended into `SettingsScreen`'s API-key field
19
- * renders as nothing and silently breaks authentication -- the exact accident
20
- * class that function's own doc records. `stripEnterFrames` is the equivalent
21
- * guard for overlays with no paste model of their own.
22
- */
23
- import { type FrameSegment } from './paste-frames.js';
1
+ export type ComposerFrame = {
2
+ kind: 'text';
3
+ text: string;
4
+ } | {
5
+ kind: 'paste';
6
+ text: string;
7
+ } | {
8
+ kind: 'newline';
9
+ } | {
10
+ kind: 'submit';
11
+ };
24
12
  /** True when this `input` carries at least one Enter frame -- the branch guard. */
25
13
  export declare function hasEnterFrame(input: string): boolean;
26
14
  /**
27
- * Split an `input` string into ordered text runs and newline runs.
28
- *
29
- * Text-only by construction (paste frames are `mergeWithPasteRuns`'s job), so
30
- * the paste id never has to exist here -- `PromptInput` allocates ids for paste
31
- * segments while building the reducer's action, exactly as the paste branch
32
- * does (P1-5: the reducer stays pure and this stays a function of its input).
15
+ * Decode paste regions before interpreting CR/LF or newline frames as keys.
16
+ * Paste IDs remain the caller's responsibility.
33
17
  */
34
- export declare function splitEnterFrames(input: string): FrameSegment[];
18
+ export declare function splitEnterFrames(input: string): ComposerFrame[];
35
19
  /**
36
20
  * Merge newline runs with the paste frames that share the same `input`.
37
21
  *
@@ -48,7 +32,7 @@ export declare function splitEnterFrames(input: string): FrameSegment[];
48
32
  * Paste segments carry NO id here (P1-5); the caller assigns one per segment
49
33
  * only when it builds the reducer's action.
50
34
  */
51
- export declare function mergeWithPasteRuns(input: string, runs: FrameSegment[]): FrameSegment[];
35
+ export declare function mergeWithPasteRuns(input: string, runs: ComposerFrame[]): ComposerFrame[];
52
36
  /**
53
37
  * Remove every Enter frame for a consumer that has no newline model of its
54
38
  * own, replacing each frame with `'\n'` (tui-shift-enter-copy-queue 3.5, the
@@ -1,25 +1,3 @@
1
- /**
2
- * Reading Enter frames back out of an Ink `input` string
3
- * (tui-shift-enter-copy-queue 3.5) -- pure.
4
- *
5
- * The stdin filter rewrites a modified Enter into `ENTER_NEWLINE_FRAME` inline,
6
- * in stream order, exactly the way it frames a paste. This module is the only
7
- * place that takes those frames apart again, and it handles BOTH newline
8
- * sources the composer can see in one `input` string:
9
- *
10
- * - `ENTER_NEWLINE_FRAME` -- Shift+Enter / Alt+Enter / CSI-u Enter, translated
11
- * by the filter; and
12
- * - a bare `\n` -- Ctrl+J, which Ink parses to `name: 'enter'` with
13
- * `input === '\n'` and NO `key.return`, so it reaches this branch as an
14
- * ordinary (control) character that must still mean "newline".
15
- *
16
- * I-12, THE BROADCAST OBLIGATION, APPLIES TO THESE FRAMES TOO. `useInput`
17
- * re-emits every chunk to every mounted handler, `stripPasteFrames` does not
18
- * know this frame, and a NUL appended into `SettingsScreen`'s API-key field
19
- * renders as nothing and silently breaks authentication -- the exact accident
20
- * class that function's own doc records. `stripEnterFrames` is the equivalent
21
- * guard for overlays with no paste model of their own.
22
- */
23
1
  import { ENTER_NEWLINE_FRAME, PASTE_CLOSE, PASTE_OPEN } from '../input/limits.js';
24
2
  import { sanitisePaste } from '../input/paste-parse.js';
25
3
  import { sanitiseTyped } from './paste-frames.js';
@@ -28,10 +6,10 @@ export function hasEnterFrame(input) {
28
6
  return input.includes(ENTER_NEWLINE_FRAME);
29
7
  }
30
8
  /**
31
- * Split one Enter-frame-free span into ordered text / newline segments.
9
+ * Split one paste-free span into ordered text, newline and submit intents.
32
10
  *
33
11
  * Every newline source -- frame or bare `\n` -- yields ONE
34
- * `{ kind: 'text', text: '\n' }` segment, so the editor's existing `input`
12
+ * `{ kind: 'newline' }` segment, so the editor's existing `input`
35
13
  * action inserts it through the same path typing uses and the draft's row
36
14
  * budget applies unchanged. Non-newline text is accumulated and passed through
37
15
  * `sanitiseTyped` (I-14): a chunk that carries a frame can also carry the
@@ -51,13 +29,19 @@ function splitEnterSpan(span) {
51
29
  while (i < span.length) {
52
30
  if (span.startsWith(ENTER_NEWLINE_FRAME, i)) {
53
31
  pushText();
54
- segments.push({ kind: 'text', text: '\n' });
32
+ segments.push({ kind: 'newline' });
55
33
  i += ENTER_NEWLINE_FRAME.length;
56
34
  continue;
57
35
  }
36
+ if (span[i] === '\r') {
37
+ pushText();
38
+ segments.push({ kind: 'submit' });
39
+ i += 1;
40
+ continue;
41
+ }
58
42
  if (span[i] === '\n') {
59
43
  pushText();
60
- segments.push({ kind: 'text', text: '\n' });
44
+ segments.push({ kind: 'newline' });
61
45
  i += 1;
62
46
  continue;
63
47
  }
@@ -68,15 +52,12 @@ function splitEnterSpan(span) {
68
52
  return segments;
69
53
  }
70
54
  /**
71
- * Split an `input` string into ordered text runs and newline runs.
72
- *
73
- * Text-only by construction (paste frames are `mergeWithPasteRuns`'s job), so
74
- * the paste id never has to exist here -- `PromptInput` allocates ids for paste
75
- * segments while building the reducer's action, exactly as the paste branch
76
- * does (P1-5: the reducer stays pure and this stays a function of its input).
55
+ * Decode paste regions before interpreting CR/LF or newline frames as keys.
56
+ * Paste IDs remain the caller's responsibility.
77
57
  */
78
58
  export function splitEnterFrames(input) {
79
- return splitEnterSpan(input);
59
+ return input.includes(PASTE_OPEN)
60
+ ? mergeWithPasteRuns(input, []) : splitEnterSpan(input);
80
61
  }
81
62
  /**
82
63
  * Merge newline runs with the paste frames that share the same `input`.