agent-from-zero 0.32.2 → 0.32.4

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/dist/ink/app.js CHANGED
@@ -36,7 +36,10 @@ const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧",
36
36
  // <Static> (formatted as markdown) the moment it finishes.
37
37
  function liveTail(s) {
38
38
  const rows = process.stdout.rows || 24;
39
- const maxLines = Math.max(3, rows - 14); // reserve rows for the input box + status bar + agent bar + margins
39
+ // Keep the preview SMALL (≤10 lines) even on a tall terminal: Ink repaints this
40
+ // whole region on every (throttled) update, and a smaller block repaints with
41
+ // far less flicker. The full reply still lands in <Static> on commit.
42
+ const maxLines = Math.max(3, Math.min(10, rows - 14)); // reserve rows for the input box + status bar + agent bar + margins
40
43
  const cols = process.stdout.columns || 80;
41
44
  const maxChars = maxLines * cols; // rough bound so one very long line can't blow past either
42
45
  let t = s.length > maxChars ? s.slice(-maxChars) : s;
@@ -95,6 +98,7 @@ export function App({ session, runTurn }) {
95
98
  const [status, setStatus] = useState(null); // the live spinner line
96
99
  const [live, setLive] = useState(null); // the streaming answer
97
100
  const [input, setInput] = useState(""); // the current input buffer
101
+ const [cursor, setCursor] = useState(0); // caret position WITHIN `input` (0..input.length), for ←/→ editing
98
102
  const [busy, setBusy] = useState(false); // a turn is in flight
99
103
  const [pending, setPending] = useState(null); // a prompt/menu blocking input
100
104
  const [menuSel, setMenuSel] = useState(0); // the select menu's cursor
@@ -462,6 +466,7 @@ export function App({ session, runTurn }) {
462
466
  setSubAgentFocus(null);
463
467
  const text = input.trim();
464
468
  setInput("");
469
+ setCursor(0);
465
470
  setHistIdx(null);
466
471
  if (!text)
467
472
  return;
@@ -469,13 +474,26 @@ export function App({ session, runTurn }) {
469
474
  history.push(text); // remember non-empty entries for ↑/↓
470
475
  void submitLine(text);
471
476
  }
477
+ else if (key.leftArrow) {
478
+ setCursor((c) => Math.max(0, c - 1)); // move the caret left within the line
479
+ }
480
+ else if (key.rightArrow) {
481
+ setCursor((c) => Math.min(input.length, c + 1)); // ...and right
482
+ }
483
+ else if (key.ctrl && char === "a") {
484
+ setCursor(0); // Home — jump to the start of the line
485
+ }
486
+ else if (key.ctrl && char === "e") {
487
+ setCursor(input.length); // End — jump to the end of the line
488
+ }
472
489
  else if (key.upArrow) {
473
- // ↑ recall an earlier prompt (newest-first), like a shell.
490
+ // ↑ recall an earlier prompt (newest-first), like a shell; caret to its end.
474
491
  if (!history.length)
475
492
  return;
476
493
  const idx = histIdx === null ? history.length - 1 : Math.max(0, histIdx - 1);
477
494
  setHistIdx(idx);
478
495
  setInput(history[idx]);
496
+ setCursor(history[idx].length);
479
497
  }
480
498
  else if (key.downArrow) {
481
499
  // ↓ walk back toward the fresh line.
@@ -485,24 +503,32 @@ export function App({ session, runTurn }) {
485
503
  if (idx >= history.length) {
486
504
  setHistIdx(null);
487
505
  setInput("");
506
+ setCursor(0);
488
507
  }
489
508
  else {
490
509
  setHistIdx(idx);
491
510
  setInput(history[idx]);
511
+ setCursor(history[idx].length);
492
512
  }
493
513
  }
494
514
  else if (key.backspace || key.delete) {
495
- setInput((s) => s.slice(0, -1));
515
+ // Delete the char BEFORE the caret. Both Backspace and macOS DEL land here,
516
+ // so this is always a backward delete (forward-delete is rare; we skip it).
517
+ if (cursor > 0) {
518
+ setInput(input.slice(0, cursor - 1) + input.slice(cursor));
519
+ setCursor(cursor - 1);
520
+ }
496
521
  }
497
522
  else if (char && !key.ctrl && !key.meta) {
498
- // A bulk chunk (a paste / a dropped file path) is normalized: a dropped
499
- // file becomes a clean absolute path; ordinary pasted prose is unchanged.
523
+ // Insert at the caret (a bulk chunk — paste / dropped file path — is
524
+ // normalized: a dropped file becomes a clean absolute path; prose unchanged).
500
525
  const add = char.length > 1 ? normalizeDroppedPaths(char) : char;
501
526
  setHistIdx(null);
502
- setInput((s) => s + add);
527
+ setInput(input.slice(0, cursor) + add + input.slice(cursor));
528
+ setCursor(cursor + add.length);
503
529
  }
504
530
  });
505
- return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Static, { items: items, children: (item, i) => _jsx(ItemView, { item: item }, i) }), live !== null && (_jsxs(Box, { marginTop: 1, flexDirection: "row", children: [_jsx(Text, { color: "green", children: "\u23FA " }), _jsx(Box, { flexGrow: 1, children: _jsx(Text, { children: live === "" ? chalk.dim("…") : liveTail(live) }) })] })), (status !== null || (busy && live === null)) && (_jsxs(Box, { marginTop: live === null ? 1 : 0, children: [_jsx(Spinner, {}), _jsxs(Text, { children: [" ", status ?? chalk.dim("working…")] })] })), pending?.kind === "select" && (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { color: "yellow", children: pending.header }), _jsx(Text, { children: renderMenu(pending.options, menuSel) }), _jsx(Text, { children: MENU_HINT })] })), pending?.kind === "form" && formState && (_jsx(Box, { marginTop: 1, children: _jsx(Text, { children: renderForm(pending.questions, formState) }) })), _jsxs(Box, { borderStyle: "round", borderColor: planMode ? "magenta" : "cyan", paddingX: 1, marginTop: 1, children: [_jsx(Text, { color: planMode ? "magenta" : "cyan", children: planMode ? "plan ❯ " : "❯ " }), _jsx(Text, { children: input }), _jsx(Text, { inverse: true, children: " " })] }), (() => {
531
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Static, { items: items, children: (item, i) => _jsx(ItemView, { item: item }, i) }), live !== null && (_jsxs(Box, { marginTop: 1, flexDirection: "row", children: [_jsx(Text, { color: "green", children: "\u23FA " }), _jsx(Box, { flexGrow: 1, children: _jsx(Text, { children: live === "" ? chalk.dim("…") : liveTail(live) }) })] })), (status !== null || (busy && live === null)) && (_jsxs(Box, { marginTop: live === null ? 1 : 0, children: [_jsx(Spinner, {}), _jsxs(Text, { children: [" ", status ?? chalk.dim("working…")] })] })), pending?.kind === "select" && (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { color: "yellow", children: pending.header }), _jsx(Text, { children: renderMenu(pending.options, menuSel) }), _jsx(Text, { children: MENU_HINT })] })), pending?.kind === "form" && formState && (_jsx(Box, { marginTop: 1, children: _jsx(Text, { children: renderForm(pending.questions, formState) }) })), _jsxs(Box, { borderStyle: "round", borderColor: planMode ? "magenta" : "cyan", paddingX: 1, marginTop: 1, children: [_jsx(Text, { color: planMode ? "magenta" : "cyan", children: planMode ? "plan ❯ " : "❯ " }), _jsx(Text, { children: input.slice(0, cursor) }), _jsx(Text, { inverse: true, children: input.slice(cursor, cursor + 1) || " " }), _jsx(Text, { children: input.slice(cursor + 1) })] }), (() => {
506
532
  const agents = listSubAgents();
507
533
  if (!agents.length)
508
534
  return null;
package/dist/ink/sink.js CHANGED
@@ -29,15 +29,28 @@ export function makeInkSink(api) {
29
29
  // The streamed answer accumulates in the dynamic region as raw text (live,
30
30
  // "it's typing"), then commits as ONE markdown-rendered item when it ends —
31
31
  // partial markdown mid-stream renders badly, so we format only once it's whole.
32
+ //
33
+ // THROTTLED on purpose: a fast stream delivers tokens dozens of times a
34
+ // second, and Ink erases+redraws the whole live region on every state change
35
+ // — repainting a multi-line block that often is what makes a long answer
36
+ // "shake" violently. So we coalesce a burst of tokens into ONE setLive every
37
+ // ~90ms (≈11 fps, smooth but cheap). The final, complete answer still commits
38
+ // to <Static> on end(), so nothing is lost or under-rendered.
32
39
  answer() {
33
40
  let buf = "";
41
+ let timer = null;
34
42
  api.setLive("");
35
43
  return {
36
44
  push: (tok) => {
37
45
  buf += tok;
38
- api.setLive(buf);
46
+ if (!timer)
47
+ timer = setTimeout(() => { timer = null; api.setLive(buf); }, 90); // leading-edge throttle: schedule one repaint, ignore the burst until it fires
39
48
  },
40
49
  end: () => {
50
+ if (timer) {
51
+ clearTimeout(timer);
52
+ timer = null;
53
+ } // drop the pending repaint — we're committing now
41
54
  api.setLive(null); // clear the live preview first, then commit (no flicker/duplicate)
42
55
  if (buf.trim())
43
56
  api.pushItem({ kind: "answer", text: buf });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-from-zero",
3
- "version": "0.32.2",
3
+ "version": "0.32.4",
4
4
  "description": "Build a Claude Code-style CLI agent from scratch, with AI as your pair programmer — companion code for a 10-day tutorial (DeepSeek by default, any OpenAI-compatible model)",
5
5
  "type": "module",
6
6
  "bin": {