u-foo 2.3.30 → 2.3.32

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 (38) hide show
  1. package/package.json +5 -1
  2. package/scripts/chat-app-smoke.js +30 -0
  3. package/scripts/ink-demo.js +23 -0
  4. package/scripts/ink-smoke.js +30 -0
  5. package/scripts/ucode-app-smoke.js +36 -0
  6. package/src/chat/commandExecutor.js +6 -2
  7. package/src/chat/daemonMessageRouter.js +9 -1
  8. package/src/chat/daemonTransport.js +2 -1
  9. package/src/chat/dashboardKeyController.js +0 -40
  10. package/src/chat/dashboardView.js +0 -20
  11. package/src/chat/index.js +9 -1
  12. package/src/chat/inputSubmitHandler.js +34 -0
  13. package/src/chat/projectCloseController.js +1 -1
  14. package/src/chat/shellCommand.js +42 -0
  15. package/src/chat/transport.js +16 -3
  16. package/src/cli.js +4 -3
  17. package/src/code/agent.js +4 -0
  18. package/src/code/nativeRunner.js +74 -0
  19. package/src/code/taskDecomposer.js +5 -4
  20. package/src/code/tui.js +73 -561
  21. package/src/daemon/index.js +169 -27
  22. package/src/daemon/ipcServer.js +23 -1
  23. package/src/daemon/promptRequest.js +6 -1
  24. package/src/daemon/run.js +11 -4
  25. package/src/projects/runtimes.js +1 -1
  26. package/src/ufoo/agentRegistryDiagnostics.js +43 -0
  27. package/src/ui/MIGRATION.md +382 -0
  28. package/src/ui/components/ChatApp.js +2950 -0
  29. package/src/ui/components/DashboardBar.js +417 -0
  30. package/src/ui/components/InkDemo.js +96 -0
  31. package/src/ui/components/MultilineInput.js +387 -0
  32. package/src/ui/components/UcodeApp.js +813 -0
  33. package/src/ui/components/agentMirror.js +725 -0
  34. package/src/ui/components/chatReducer.js +337 -0
  35. package/src/ui/format/index.js +997 -0
  36. package/src/ui/index.js +9 -0
  37. package/src/ui/runInk.js +57 -0
  38. package/src/utils/nodeExecutable.js +26 -0
@@ -0,0 +1,387 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * Multiline text input for the ink-based ucode TUI.
5
+ *
6
+ * Mirrors the behaviour of the blessed `_listener` in src/code/tui.js, but
7
+ * built on ink's useInput. Cursor math is delegated to src/ui/format so the
8
+ * legacy and ink editors stay in sync (and so the existing jest coverage of
9
+ * those helpers protects this component too).
10
+ *
11
+ * Props:
12
+ * value (string) text contents (controlled)
13
+ * onChange(nextValue) called when value changes
14
+ * onSubmit(value) Enter pressed without modifiers, no trailing `\`
15
+ * onCancel() Esc pressed; the parent decides what to do
16
+ * (e.g. abort an in-flight task). The component
17
+ * does NOT mutate `value` on cancel.
18
+ * onArrowUpAtTop(value) cursor on the first visual row, Up pressed
19
+ * onArrowDownAtBottom(value) cursor on the last visual row, Down pressed
20
+ * onArrowLeftAtEmpty(value) Left pressed while value is empty
21
+ * onArrowRightAtEmpty(value) Right pressed while value is empty
22
+ * width (number) wrap width in display cells
23
+ * interactive (boolean) gates useInput; pass false for non-TTY mounts
24
+ * interceptArrowsAndEnter (boolean)
25
+ * when true, Up/Down/Left/Right/Return are
26
+ * suppressed inside the editor so a parent
27
+ * component (e.g. completion popup) can
28
+ * handle them. Plain editing keys still work.
29
+ * placeholder (string) rendered in gray when value is empty
30
+ *
31
+ * Newlines: Enter submits. Use Alt+Enter (delivered as meta+Return) or end the
32
+ * line with `\` (the legacy continuation trick) to insert a literal newline.
33
+ * We do NOT rely on Shift+Enter — many terminals don't distinguish it from
34
+ * plain Enter, so it would silently submit.
35
+ *
36
+ * Bracketed paste arrives as a multi-byte `input` chunk in useInput; we route
37
+ * it through insertText, so multi-line paste already works without extra code.
38
+ */
39
+
40
+ const fmt = require("../format");
41
+
42
+ function createMultilineInput({ React, ink }) {
43
+ const { useState, useCallback, useMemo, useEffect } = React;
44
+ const { Box, Text, useInput } = ink;
45
+ const h = React.createElement;
46
+
47
+ return function MultilineInput({
48
+ value = "",
49
+ valueVersion = 0,
50
+ onChange = () => {},
51
+ onSubmit = () => {},
52
+ onCancel = () => {},
53
+ onArrowUpAtTop,
54
+ onArrowDownAtBottom,
55
+ onArrowLeftAtEmpty,
56
+ onArrowRightAtEmpty,
57
+ width = 80,
58
+ interactive = true,
59
+ interceptArrowsAndEnter = false,
60
+ placeholder = "",
61
+ promptPrefix = "› ",
62
+ promptColor = "magenta",
63
+ borderColor = "gray",
64
+ }) {
65
+ // Cursor is owned by this component. preferredCol tracks the visual
66
+ // column we want to keep when bouncing across lines of different widths
67
+ // via Up/Down.
68
+ const [cursorState, setCursorState] = useState(() => String(value || "").length);
69
+ const [preferredCol, setPreferredCol] = useState(null);
70
+ const cursorPos = fmt.clampCursorPos(cursorState, value);
71
+
72
+ // When the parent forces a new value via valueVersion (e.g. accepting
73
+ // a completion), park the cursor at the end of the freshly inserted
74
+ // text so the user can keep typing without arrow-keying back to the
75
+ // tail.
76
+ useEffect(() => {
77
+ setCursorState(String(value || "").length);
78
+ setPreferredCol(null);
79
+ // eslint-disable-next-line react-hooks/exhaustive-deps
80
+ }, [valueVersion]);
81
+
82
+ const wrapWidth = Math.max(1, Math.floor(Number(width) || 80));
83
+
84
+ const setCursor = useCallback((next) => {
85
+ setCursorState(fmt.clampCursorPos(next, value));
86
+ }, [value]);
87
+ const resetPreferredCol = useCallback(() => setPreferredCol(null), []);
88
+
89
+ const change = useCallback((nextValue, nextCursor) => {
90
+ const clamped = fmt.clampCursorPos(nextCursor, nextValue);
91
+ setCursorState(clamped);
92
+ onChange(nextValue);
93
+ }, [onChange]);
94
+
95
+ const insertText = useCallback((text) => {
96
+ const before = value.slice(0, cursorPos);
97
+ const after = value.slice(cursorPos);
98
+ change(`${before}${text}${after}`, cursorPos + text.length);
99
+ setPreferredCol(null);
100
+ }, [value, cursorPos, change]);
101
+
102
+ const replaceRange = useCallback((start, end, text) => {
103
+ const safeStart = Math.max(0, Math.min(value.length, start));
104
+ const safeEnd = Math.max(safeStart, Math.min(value.length, end));
105
+ const next = `${value.slice(0, safeStart)}${text}${value.slice(safeEnd)}`;
106
+ change(next, safeStart + text.length);
107
+ setPreferredCol(null);
108
+ }, [value, change]);
109
+
110
+ const deleteBefore = useCallback(() => {
111
+ if (cursorPos <= 0) return;
112
+ replaceRange(cursorPos - 1, cursorPos, "");
113
+ }, [cursorPos, replaceRange]);
114
+
115
+ const deleteAt = useCallback(() => {
116
+ if (cursorPos >= value.length) return;
117
+ replaceRange(cursorPos, cursorPos + 1, "");
118
+ }, [cursorPos, value.length, replaceRange]);
119
+
120
+ const deleteToBoundary = useCallback((boundary) => {
121
+ const target = fmt.moveCursorToVisualLineBoundary({
122
+ cursorPos,
123
+ inputValue: value,
124
+ width: wrapWidth,
125
+ boundary,
126
+ });
127
+ const start = Math.min(cursorPos, target);
128
+ const end = Math.max(cursorPos, target);
129
+ if (start === end) return;
130
+ replaceRange(start, end, "");
131
+ }, [cursorPos, value, wrapWidth, replaceRange]);
132
+
133
+ const deleteWordBefore = useCallback(() => {
134
+ const next = fmt.deleteWordBeforeCursor(value, cursorPos);
135
+ change(next.value, next.cursorPos);
136
+ setPreferredCol(null);
137
+ }, [value, cursorPos, change]);
138
+
139
+ const deleteWordAfter = useCallback(() => {
140
+ const end = fmt.moveCursorByWord(value, cursorPos, "forward");
141
+ replaceRange(cursorPos, end, "");
142
+ }, [value, cursorPos, replaceRange]);
143
+
144
+ useInput((input, key) => {
145
+ // Let the parent absorb arrow keys + Enter when a popup (e.g. the
146
+ // completion list) is open. We still process plain text input so
147
+ // typing continues to filter the popup live.
148
+ if (interceptArrowsAndEnter && (
149
+ key.upArrow || key.downArrow || key.leftArrow || key.rightArrow || key.return
150
+ )) {
151
+ return;
152
+ }
153
+ // Submit: Enter without modifiers, except after a trailing backslash
154
+ // (which is the legacy "\\\n" continuation trick). Alt+Enter inserts
155
+ // a literal newline. Shift+Enter is intentionally NOT used: many
156
+ // terminals don't distinguish it from plain Enter.
157
+ if (key.return) {
158
+ if (key.meta) {
159
+ insertText("\n");
160
+ return;
161
+ }
162
+ if (cursorPos > 0 && value[cursorPos - 1] === "\\") {
163
+ replaceRange(cursorPos - 1, cursorPos, "\n");
164
+ return;
165
+ }
166
+ onSubmit(value);
167
+ return;
168
+ }
169
+ if (key.escape) {
170
+ onCancel();
171
+ return;
172
+ }
173
+ if (key.ctrl) {
174
+ if (input === "a") {
175
+ setCursor(fmt.moveCursorToVisualLineBoundary({
176
+ cursorPos, inputValue: value, width: wrapWidth, boundary: "start",
177
+ }));
178
+ resetPreferredCol();
179
+ return;
180
+ }
181
+ if (input === "e") {
182
+ setCursor(fmt.moveCursorToVisualLineBoundary({
183
+ cursorPos, inputValue: value, width: wrapWidth, boundary: "end",
184
+ }));
185
+ resetPreferredCol();
186
+ return;
187
+ }
188
+ if (input === "b") {
189
+ setCursor(fmt.moveCursorHorizontally(cursorPos, value, "left"));
190
+ resetPreferredCol();
191
+ return;
192
+ }
193
+ if (input === "f") {
194
+ setCursor(fmt.moveCursorHorizontally(cursorPos, value, "right"));
195
+ resetPreferredCol();
196
+ return;
197
+ }
198
+ if (input === "d") { deleteAt(); return; }
199
+ if (input === "h") { deleteBefore(); return; }
200
+ if (input === "k") { deleteToBoundary("end"); return; }
201
+ if (input === "u") { deleteToBoundary("start"); return; }
202
+ if (input === "w") { deleteWordBefore(); return; }
203
+ // Ctrl+C is parent's responsibility (typically exits the app).
204
+ return;
205
+ }
206
+ if (key.meta) {
207
+ if (input === "b") {
208
+ setCursor(fmt.moveCursorByWord(value, cursorPos, "backward"));
209
+ resetPreferredCol();
210
+ return;
211
+ }
212
+ if (input === "f") {
213
+ setCursor(fmt.moveCursorByWord(value, cursorPos, "forward"));
214
+ resetPreferredCol();
215
+ return;
216
+ }
217
+ if (input === "d") { deleteWordAfter(); return; }
218
+ }
219
+ if (key.backspace) {
220
+ if (key.meta || key.ctrl) deleteWordBefore();
221
+ else deleteBefore();
222
+ return;
223
+ }
224
+ if (key.delete) {
225
+ // ink reports key.delete for the 0x7F byte that most terminals send
226
+ // when the user presses the top-left Delete key (a.k.a. Backspace on
227
+ // non-Mac keyboards). Treat it as "delete the character before the
228
+ // cursor" by default. Real forward-delete (Fn+Delete on macOS) sends
229
+ // an escape sequence and ink also sets key.delete with no leading
230
+ // input — we can't reliably tell them apart, so favour the much
231
+ // more common backspace semantics. Meta+Delete still maps to
232
+ // delete-to-line-end as before.
233
+ if (key.meta) deleteToBoundary("end");
234
+ else deleteBefore();
235
+ return;
236
+ }
237
+ if (key.leftArrow) {
238
+ if (!value && typeof onArrowLeftAtEmpty === "function") {
239
+ onArrowLeftAtEmpty(value);
240
+ return;
241
+ }
242
+ setCursor(fmt.moveCursorHorizontally(cursorPos, value, "left"));
243
+ resetPreferredCol();
244
+ return;
245
+ }
246
+ if (key.rightArrow) {
247
+ if (!value && typeof onArrowRightAtEmpty === "function") {
248
+ onArrowRightAtEmpty(value);
249
+ return;
250
+ }
251
+ setCursor(fmt.moveCursorHorizontally(cursorPos, value, "right"));
252
+ resetPreferredCol();
253
+ return;
254
+ }
255
+ if (key.upArrow) {
256
+ if (value) {
257
+ const move = fmt.moveCursorVertically({
258
+ cursorPos, inputValue: value, width: wrapWidth,
259
+ direction: "up", preferredCol,
260
+ });
261
+ setPreferredCol(move.preferredCol);
262
+ if (move.moved) {
263
+ setCursor(move.nextCursorPos);
264
+ return;
265
+ }
266
+ }
267
+ if (typeof onArrowUpAtTop === "function") onArrowUpAtTop(value);
268
+ return;
269
+ }
270
+ if (key.downArrow) {
271
+ if (value) {
272
+ const move = fmt.moveCursorVertically({
273
+ cursorPos, inputValue: value, width: wrapWidth,
274
+ direction: "down", preferredCol,
275
+ });
276
+ setPreferredCol(move.preferredCol);
277
+ if (move.moved) {
278
+ setCursor(move.nextCursorPos);
279
+ return;
280
+ }
281
+ }
282
+ if (typeof onArrowDownAtBottom === "function") onArrowDownAtBottom(value);
283
+ return;
284
+ }
285
+
286
+ // Plain character / paste. Filter control bytes.
287
+ if (input && !key.ctrl && !key.meta) {
288
+ const filtered = input.replace(/[\x00-\x08\x0b-\x0c\x0e-\x1f\x7f]/g, "");
289
+ if (filtered) insertText(filtered);
290
+ }
291
+ }, { isActive: interactive });
292
+
293
+ // Render: split into logical lines, then split each into visual rows by
294
+ // wrap width. Highlight one cell at the cursor location. With a
295
+ // placeholder, we still draw the cursor (visible at offset 0) and append
296
+ // the placeholder text in gray after it.
297
+ const showPlaceholder = !value && !!placeholder;
298
+ const visualRows = useMemo(
299
+ () => layoutRows(value, wrapWidth, cursorPos),
300
+ [value, wrapWidth, cursorPos]
301
+ );
302
+
303
+ return h(Box, {
304
+ borderStyle: "single",
305
+ borderTop: true,
306
+ borderBottom: true,
307
+ borderLeft: false,
308
+ borderRight: false,
309
+ borderColor,
310
+ flexDirection: "column",
311
+ width: "100%",
312
+ },
313
+ ...visualRows.map((row, idx) =>
314
+ h(Box, { key: `row-${idx}` },
315
+ idx === 0 ? h(Text, { color: promptColor }, promptPrefix) : h(Text, null, " "),
316
+ ...row.segments.map((seg, segIdx) =>
317
+ h(Text, {
318
+ key: `s-${segIdx}`,
319
+ inverse: seg.cursor,
320
+ color: showPlaceholder && idx === 0 && segIdx === 0 ? "gray" : undefined,
321
+ }, seg.text)
322
+ ),
323
+ showPlaceholder && idx === 0
324
+ ? h(Text, { color: "gray" }, placeholder)
325
+ : null,
326
+ )
327
+ ),
328
+ );
329
+ };
330
+ }
331
+
332
+ /**
333
+ * Lay out `value` into visual rows respecting `width`, and mark the cell at
334
+ * `cursor` so the renderer can invert it. Returns:
335
+ * [{ segments: [{ text, cursor }] }, ...]
336
+ *
337
+ * Cursor at end-of-input is rendered as an inverted space appended to the
338
+ * final row. Newlines split rows but never appear in segments. Pass
339
+ * `cursor < 0` to suppress the cursor entirely (used in placeholder mode).
340
+ */
341
+ function layoutRows(value, width, cursor) {
342
+ const text = String(value == null ? "" : value);
343
+ const safeWidth = Math.max(1, Math.floor(Number(width) || 1));
344
+ const rawCursor = Number(cursor);
345
+ const showCursor = Number.isFinite(rawCursor) && rawCursor >= 0;
346
+ const cursorIdx = showCursor
347
+ ? Math.min(text.length, Math.floor(rawCursor))
348
+ : -1;
349
+
350
+ const rows = [];
351
+ let row = { segments: [], cellsUsed: 0, cursorPlaced: false };
352
+ const pushRow = () => {
353
+ rows.push(row);
354
+ row = { segments: [], cellsUsed: 0, cursorPlaced: false };
355
+ };
356
+
357
+ for (let i = 0; i < text.length; i += 1) {
358
+ const ch = text[i];
359
+ if (ch === "\n") {
360
+ if (cursorIdx === i && !row.cursorPlaced) {
361
+ row.segments.push({ text: " ", cursor: true });
362
+ row.cursorPlaced = true;
363
+ }
364
+ pushRow();
365
+ continue;
366
+ }
367
+ const w = fmt.displayCellWidth(ch);
368
+ if (row.cellsUsed + w > safeWidth) pushRow();
369
+ if (cursorIdx === i) {
370
+ row.segments.push({ text: ch, cursor: true });
371
+ row.cursorPlaced = true;
372
+ } else {
373
+ row.segments.push({ text: ch, cursor: false });
374
+ }
375
+ row.cellsUsed += w;
376
+ }
377
+ if (cursorIdx === text.length && !row.cursorPlaced) {
378
+ row.segments.push({ text: " ", cursor: true });
379
+ }
380
+ rows.push(row);
381
+ return rows;
382
+ }
383
+
384
+ module.exports = {
385
+ createMultilineInput,
386
+ layoutRows,
387
+ };