@nanhara/hara 0.120.0 → 0.121.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.
@@ -14,10 +14,11 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
14
14
  // of leaning on ink's soft-wrap of a single <Text>, which mis-aligns wrapped rows against the
15
15
  // "› " prompt gutter and reflows unpredictably as you type.
16
16
  import { Box, Text, useInput, useStdout } from "ink";
17
- import { memo, useMemo, useState } from "react";
17
+ import { memo, useMemo, useRef, useState } from "react";
18
18
  import { fileCandidates } from "../context/mentions.js";
19
19
  import { imagePathFromPaste } from "../images.js";
20
20
  import { vimNormal } from "./vim.js";
21
+ import { ComposerHistory, moveCursorLine, nextGraphemeIndex, previousGraphemeIndex, previousWordIndex, } from "./input-history.js";
21
22
  export const MODES = ["suggest", "auto-edit", "full-auto", "plan"];
22
23
  export const nextMode = (m) => MODES[(MODES.indexOf(m) + 1) % MODES.length];
23
24
  const tok = (n) => (n >= 1000 ? `${(n / 1000).toFixed(1)}k` : `${n}`);
@@ -312,15 +313,30 @@ export function InputBox({ status, cwd, model, route, width, onSubmit, onClipboa
312
313
  const [mode, setMode] = useState("insert"); // vim only
313
314
  const [pending, setPending] = useState(""); // vim operator-pending (d/c/g)
314
315
  const [register, setRegister] = useState(""); // vim yank/delete register
316
+ const historyRef = useRef(null);
317
+ if (!historyRef.current)
318
+ historyRef.current = new ComposerHistory();
319
+ const inputHistory = historyRef.current;
315
320
  const set = (v, c) => {
321
+ inputHistory.abandonNavigation();
316
322
  setValue(v);
317
323
  setCursor(c);
318
324
  setSel(0);
319
325
  setDismissed(false);
320
326
  };
327
+ const snapshot = () => ({ value, attachments: images, pastes });
328
+ const restore = (draft) => {
329
+ setValue(draft.value);
330
+ setCursor(draft.value.length);
331
+ setImages(draft.attachments);
332
+ setPastes(draft.pastes);
333
+ setSel(0);
334
+ setDismissed(false);
335
+ };
321
336
  // Attach an image: drop a highlighted `[Image #N]` token inline at the cursor and track the file
322
337
  // (codex / Claude-Code style). Backspace over the token removes both.
323
338
  const addImage = (img) => {
339
+ inputHistory.abandonNavigation();
324
340
  const tok = `[Image #${images.length + 1}]`;
325
341
  const before = value.slice(0, cursor);
326
342
  const ins = (before && !/\s$/.test(before) ? " " : "") + tok + " ";
@@ -334,6 +350,7 @@ export function InputBox({ status, cwd, model, route, width, onSubmit, onClipboa
334
350
  // the box: typing stays smooth (the VALUE stays short), the box stays small, and a multi-line paste
335
351
  // can no longer fire the newline-submit path mid-paste. Expanded back to the full text on submit.
336
352
  const addPaste = (text) => {
353
+ inputHistory.abandonNavigation();
337
354
  const lines = text.split("\n").length;
338
355
  const tok = `[Paste #${pastes.length + 1} +${lines} lines]`;
339
356
  const before = value.slice(0, cursor);
@@ -348,6 +365,7 @@ export function InputBox({ status, cwd, model, route, width, onSubmit, onClipboa
348
365
  const submit = (text) => {
349
366
  if (!text.trim() && images.length === 0)
350
367
  return; // nothing to send
368
+ inputHistory.record(snapshot());
351
369
  onSubmit?.(expandPastes(text), images.length ? images : undefined);
352
370
  set("", 0);
353
371
  setImages([]);
@@ -365,6 +383,7 @@ export function InputBox({ status, cwd, model, route, width, onSubmit, onClipboa
365
383
  const complete = (cand) => {
366
384
  if (!mention)
367
385
  return;
386
+ inputHistory.abandonNavigation();
368
387
  const before = value.slice(0, mention.start); // includes the leading '@'
369
388
  const after = value.slice(cursor);
370
389
  const insert = cand.endsWith("/") ? cand : cand + " "; // dirs keep drilling; files end the mention
@@ -406,6 +425,8 @@ export function InputBox({ status, cwd, model, route, width, onSubmit, onClipboa
406
425
  return setCursor((c) => Math.max(0, c - 1));
407
426
  if (input && !key.ctrl && !key.meta) {
408
427
  const st = vimNormal({ value, cursor, mode, pending, register }, input);
428
+ if (st.value !== value)
429
+ inputHistory.abandonNavigation();
409
430
  setValue(st.value);
410
431
  setCursor(st.cursor);
411
432
  setMode(st.mode);
@@ -416,20 +437,54 @@ export function InputBox({ status, cwd, model, route, width, onSubmit, onClipboa
416
437
  }
417
438
  return;
418
439
  }
440
+ if (key.upArrow) {
441
+ const onFirstLine = cursor === 0 || value.lastIndexOf("\n", cursor - 1) < 0;
442
+ if (onFirstLine) {
443
+ const draft = inputHistory.older(snapshot());
444
+ if (draft)
445
+ restore(draft);
446
+ }
447
+ else {
448
+ setCursor(moveCursorLine(value, cursor, -1));
449
+ }
450
+ return;
451
+ }
452
+ if (key.downArrow) {
453
+ const onLastLine = value.indexOf("\n", cursor) < 0;
454
+ if (onLastLine) {
455
+ const draft = inputHistory.newer();
456
+ if (draft)
457
+ restore(draft);
458
+ }
459
+ else {
460
+ setCursor(moveCursorLine(value, cursor, 1));
461
+ }
462
+ return;
463
+ }
419
464
  if (key.return) {
465
+ if (key.shift || key.meta) {
466
+ set(value.slice(0, cursor) + "\n" + value.slice(cursor), cursor + 1);
467
+ return;
468
+ }
420
469
  submit(value);
421
470
  return;
422
471
  }
423
472
  if (key.leftArrow)
424
- return setCursor((c) => Math.max(0, c - 1));
473
+ return setCursor((c) => previousGraphemeIndex(value, c));
425
474
  if (key.rightArrow)
426
- return setCursor((c) => Math.min(value.length, c + 1));
475
+ return setCursor((c) => nextGraphemeIndex(value, c));
427
476
  if (key.ctrl && input === "a")
428
477
  return setCursor(0);
429
478
  if (key.ctrl && input === "e")
430
479
  return setCursor(value.length);
431
480
  if (key.ctrl && input === "u")
432
481
  return set(value.slice(cursor), 0);
482
+ if (key.ctrl && input === "w") {
483
+ const start = previousWordIndex(value, cursor);
484
+ return set(value.slice(0, start) + value.slice(cursor), start);
485
+ }
486
+ if (key.ctrl && input === "k")
487
+ return set(value.slice(0, cursor), cursor);
433
488
  if (key.ctrl && input === "v") {
434
489
  // paste a screenshot / image from the OS clipboard
435
490
  const img = onClipboardImage?.();
@@ -442,6 +497,7 @@ export function InputBox({ status, cwd, model, route, width, onSubmit, onClipboa
442
497
  const head = value.slice(0, cursor);
443
498
  const pm = /\[Paste #(\d+) \+\d+ lines\]\s?$/.exec(head); // paste token deletes whole + renumbers
444
499
  if (pm) {
500
+ inputHistory.abandonNavigation();
445
501
  const n = Number(pm[1]);
446
502
  const kept = head.slice(0, pm.index) + value.slice(cursor);
447
503
  const renumbered = kept.replace(/\[Paste #(\d+)( \+\d+ lines\])/g, (m2, d, tail) => (Number(d) > n ? `[Paste #${Number(d) - 1}${tail}` : m2));
@@ -454,6 +510,7 @@ export function InputBox({ status, cwd, model, route, width, onSubmit, onClipboa
454
510
  }
455
511
  const tm = /\[Image #(\d+)\]\s?$/.exec(head); // backspacing over an attachment token removes it whole
456
512
  if (tm) {
513
+ inputHistory.abandonNavigation();
457
514
  const n = Number(tm[1]);
458
515
  const kept = head.slice(0, tm.index) + value.slice(cursor);
459
516
  const renumbered = kept.replace(/\[Image #(\d+)\]/g, (m2, d) => (Number(d) > n ? `[Image #${Number(d) - 1}]` : m2));
@@ -464,7 +521,8 @@ export function InputBox({ status, cwd, model, route, width, onSubmit, onClipboa
464
521
  setDismissed(false);
465
522
  return;
466
523
  }
467
- set(value.slice(0, cursor - 1) + value.slice(cursor), cursor - 1);
524
+ const previous = previousGraphemeIndex(value, cursor);
525
+ set(value.slice(0, previous) + value.slice(cursor), previous);
468
526
  }
469
527
  return;
470
528
  }
@@ -0,0 +1,167 @@
1
+ // Pure composer state: bounded shell-style history plus Unicode-safe cursor helpers. Keeping this out
2
+ // of the Ink component makes navigation deterministic and avoids copying a growing history on each key.
3
+ const cloneDraft = (draft) => ({
4
+ value: draft.value,
5
+ attachments: [...draft.attachments],
6
+ pastes: [...draft.pastes],
7
+ });
8
+ const draftChars = (draft) => draft.value.length + draft.pastes.reduce((sum, paste) => sum + paste.length, 0) + draft.attachments.length * 128;
9
+ /** Bounded in-process history. The draft that existed before Up is restored after navigating Down. */
10
+ export class ComposerHistory {
11
+ maxEntries;
12
+ maxChars;
13
+ entries = [];
14
+ cursor = null;
15
+ scratch = null;
16
+ totalChars = 0;
17
+ constructor(maxEntries = 100, maxChars = 2_000_000) {
18
+ this.maxEntries = maxEntries;
19
+ this.maxChars = maxChars;
20
+ }
21
+ get browsing() {
22
+ return this.cursor !== null;
23
+ }
24
+ get length() {
25
+ return this.entries.length;
26
+ }
27
+ record(draft) {
28
+ this.abandonNavigation();
29
+ const chars = draftChars(draft);
30
+ // A giant paste is already retained by the conversation history. Do not duplicate an entry larger
31
+ // than the entire composer-history budget just to support Up-arrow recall.
32
+ if (chars > this.maxChars || this.maxEntries <= 0)
33
+ return;
34
+ this.entries.push({ draft: cloneDraft(draft), chars });
35
+ this.totalChars += chars;
36
+ while (this.entries.length > this.maxEntries || this.totalChars > this.maxChars) {
37
+ this.totalChars -= this.entries.shift().chars;
38
+ }
39
+ }
40
+ older(current) {
41
+ if (!this.entries.length)
42
+ return null;
43
+ if (this.cursor === null) {
44
+ this.scratch = cloneDraft(current);
45
+ this.cursor = this.entries.length - 1;
46
+ }
47
+ else if (this.cursor > 0) {
48
+ this.cursor--;
49
+ }
50
+ return cloneDraft(this.entries[this.cursor].draft);
51
+ }
52
+ newer() {
53
+ if (this.cursor === null)
54
+ return null;
55
+ if (this.cursor < this.entries.length - 1) {
56
+ this.cursor++;
57
+ return cloneDraft(this.entries[this.cursor].draft);
58
+ }
59
+ const draft = this.scratch ?? { value: "", attachments: [], pastes: [] };
60
+ this.cursor = null;
61
+ this.scratch = null;
62
+ return cloneDraft(draft);
63
+ }
64
+ abandonNavigation() {
65
+ this.cursor = null;
66
+ this.scratch = null;
67
+ }
68
+ }
69
+ let segmenter;
70
+ function graphemeSegmenter() {
71
+ // Constructing Intl.Segmenter loads ICU data. Defer that cost until the first actual cursor edit so
72
+ // lightweight commands such as `hara --version` do not pay for TUI Unicode support.
73
+ if (segmenter === undefined)
74
+ segmenter = typeof Intl.Segmenter === "function" ? new Intl.Segmenter(undefined, { granularity: "grapheme" }) : null;
75
+ return segmenter;
76
+ }
77
+ /** Previous user-perceived character boundary (keeps emoji ZWJ sequences/combining marks intact). */
78
+ export function previousGraphemeIndex(value, cursor) {
79
+ const at = Math.max(0, Math.min(value.length, cursor));
80
+ if (at === 0)
81
+ return 0;
82
+ const segments = graphemeSegmenter();
83
+ if (!segments) {
84
+ const low = value.charCodeAt(at - 1);
85
+ const paired = low >= 0xdc00 && low <= 0xdfff && at > 1 && value.charCodeAt(at - 2) >= 0xd800 && value.charCodeAt(at - 2) <= 0xdbff;
86
+ return Math.max(0, at - (paired ? 2 : 1));
87
+ }
88
+ let previous = 0;
89
+ for (const part of segments.segment(value)) {
90
+ if (part.index >= at)
91
+ break;
92
+ previous = part.index;
93
+ }
94
+ return previous;
95
+ }
96
+ /** Next user-perceived character boundary. */
97
+ export function nextGraphemeIndex(value, cursor) {
98
+ const at = Math.max(0, Math.min(value.length, cursor));
99
+ if (at >= value.length)
100
+ return value.length;
101
+ const segments = graphemeSegmenter();
102
+ if (!segments) {
103
+ const cp = value.codePointAt(at);
104
+ return Math.min(value.length, at + (cp !== undefined && cp > 0xffff ? 2 : 1));
105
+ }
106
+ for (const part of segments.segment(value)) {
107
+ const end = part.index + part.segment.length;
108
+ if (end > at)
109
+ return end;
110
+ }
111
+ return value.length;
112
+ }
113
+ /** Start of the previous shell-style word, used by Ctrl+W. */
114
+ export function previousWordIndex(value, cursor) {
115
+ let at = Math.max(0, Math.min(value.length, cursor));
116
+ while (at > 0) {
117
+ const previous = previousGraphemeIndex(value, at);
118
+ if (!/\s/u.test(value.slice(previous, at)))
119
+ break;
120
+ at = previous;
121
+ }
122
+ while (at > 0) {
123
+ const previous = previousGraphemeIndex(value, at);
124
+ if (/\s/u.test(value.slice(previous, at)))
125
+ break;
126
+ at = previous;
127
+ }
128
+ return at;
129
+ }
130
+ function graphemeFloor(value, cursor) {
131
+ const at = Math.max(0, Math.min(value.length, cursor));
132
+ if (at === value.length)
133
+ return at;
134
+ const segments = graphemeSegmenter();
135
+ if (!segments)
136
+ return at > 0 && /[\uDC00-\uDFFF]/.test(value[at]) ? at - 1 : at;
137
+ let floor = 0;
138
+ for (const part of segments.segment(value)) {
139
+ if (part.index > at)
140
+ break;
141
+ floor = part.index;
142
+ if (part.index + part.segment.length === at)
143
+ return at;
144
+ }
145
+ return floor;
146
+ }
147
+ /** Move between logical pasted/typed lines while retaining the closest possible column. */
148
+ export function moveCursorLine(value, cursor, direction) {
149
+ const at = Math.max(0, Math.min(value.length, cursor));
150
+ const lineStart = (at === 0 ? -1 : value.lastIndexOf("\n", at - 1)) + 1;
151
+ const lineEndAt = value.indexOf("\n", at);
152
+ const lineEnd = lineEndAt < 0 ? value.length : lineEndAt;
153
+ const column = Math.min(at - lineStart, lineEnd - lineStart);
154
+ if (direction < 0) {
155
+ if (lineStart === 0)
156
+ return at;
157
+ const previousEnd = lineStart - 1;
158
+ const previousStart = value.lastIndexOf("\n", Math.max(0, previousEnd - 1)) + 1;
159
+ return graphemeFloor(value, previousStart + Math.min(column, previousEnd - previousStart));
160
+ }
161
+ if (lineEnd === value.length)
162
+ return at;
163
+ const nextStart = lineEnd + 1;
164
+ const nextEndAt = value.indexOf("\n", nextStart);
165
+ const nextEnd = nextEndAt < 0 ? value.length : nextEndAt;
166
+ return graphemeFloor(value, nextStart + Math.min(column, nextEnd - nextStart));
167
+ }
package/dist/tui/run.js CHANGED
@@ -4,36 +4,39 @@
4
4
  import { render, Box, Text, useApp, useInput } from "ink";
5
5
  import { createElement } from "react";
6
6
  import { App } from "./App.js";
7
- export async function runTui(props) {
8
- const instance = render(createElement(App, props));
9
- // Resize repaint fix. ink 6.8's own resize handler only clears the screen when the terminal gets
10
- // NARROWER; on a WIDEN (or a resize it doesn't classify as narrowing) it just re-renders, so the old
11
- // frame reflowed at the new width is never erased, and the ~125ms spinner tick stacks a fresh copy
12
- // each time (the "moved the window and the UI stacked up" garble). We complement it: on ANY resize,
13
- // clear ink's tracked output so the next render starts clean. Debounced by a microtask so a burst of
14
- // resize events during a window drag collapses to one clear.
15
- const out = process.stdout;
16
- let pending = false;
7
+ /** Ink 6.8 clears before repainting when a terminal gets narrower, but not when it gets wider. Install a
8
+ * complementary WIDTH-only clear ahead of Ink's own resize listener so Ink's normal layout pass redraws
9
+ * the frame immediately afterwards. Never clear on a rows-only resize: `instance.clear()` erases the
10
+ * dynamic region without scheduling a React render, which made an idle input box disappear when a user
11
+ * dragged only the top/bottom edge of the terminal window. Exported for a small ordering regression test. */
12
+ export function installResizeRepaint(out, instance) {
13
+ let lastColumns = out.columns;
17
14
  const onResize = () => {
18
- if (pending)
15
+ const columns = out.columns;
16
+ if (!columns || columns === lastColumns)
19
17
  return;
20
- pending = true;
21
- queueMicrotask(() => {
22
- pending = false;
23
- try {
24
- instance.clear();
25
- }
26
- catch {
27
- /* best-effort — never let a repaint fix crash the session */
28
- }
29
- });
18
+ lastColumns = columns;
19
+ try {
20
+ instance.clear();
21
+ }
22
+ catch {
23
+ /* best-effort — never let a repaint fix crash the session */
24
+ }
30
25
  };
31
- out.on("resize", onResize);
26
+ // Ink registered its listener inside render() already. Prepending is essential: clearing AFTER Ink's
27
+ // repaint is precisely what leaves the prompt blank until some unrelated state update happens.
28
+ out.prependListener("resize", onResize);
29
+ return () => out.off("resize", onResize);
30
+ }
31
+ export async function runTui(props) {
32
+ const instance = render(createElement(App, props));
33
+ const out = process.stdout;
34
+ const removeResizeRepaint = installResizeRepaint(out, instance);
32
35
  try {
33
36
  await instance.waitUntilExit();
34
37
  }
35
38
  finally {
36
- out.off("resize", onResize);
39
+ removeResizeRepaint();
37
40
  }
38
41
  }
39
42
  // A tiny ink yes/no prompt for pre-TUI confirms (e.g. the first-run "create AGENTS.md?" offer).
package/dist/undo.js CHANGED
@@ -1,7 +1,8 @@
1
1
  // In-session undo stack for file changes. Each edit tool records the prior state of the files it
2
2
  // touched; `/undo` pops the last group and restores it. Process-scoped (one REPL session).
3
- import { writeFile, unlink, mkdir } from "node:fs/promises";
4
- import { dirname } from "node:path";
3
+ import { unlink } from "node:fs/promises";
4
+ import { atomicWriteText } from "./fs-write.js";
5
+ import { invalidateFileCandidates } from "./context/mentions.js";
5
6
  const stack = [];
6
7
  const MAX = 50;
7
8
  /** Record a group of file changes (one tool call = one undo step). */
@@ -27,8 +28,7 @@ export async function undoLast() {
27
28
  await unlink(s.absPath).catch(() => { }); // was newly created → remove
28
29
  }
29
30
  else {
30
- await mkdir(dirname(s.absPath), { recursive: true });
31
- await writeFile(s.absPath, s.before, "utf8");
31
+ await atomicWriteText(s.absPath, s.before);
32
32
  }
33
33
  files.push(s.path);
34
34
  }
@@ -36,5 +36,7 @@ export async function undoLast() {
36
36
  /* skip a file we can't restore */
37
37
  }
38
38
  }
39
+ if (files.length)
40
+ invalidateFileCandidates();
39
41
  return { files };
40
42
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanhara/hara",
3
- "version": "0.120.0",
3
+ "version": "0.121.1",
4
4
  "description": "hara — a coding agent CLI that runs like an engineering org.",
5
5
  "bin": {
6
6
  "hara": "dist/index.js"
@@ -70,5 +70,10 @@
70
70
  "optionalDependencies": {
71
71
  "@zvec/zvec": "^0.5.0",
72
72
  "qrcode-terminal": "^0.12.0"
73
+ },
74
+ "overrides": {
75
+ "@larksuiteoapi/node-sdk": {
76
+ "axios": "1.18.1"
77
+ }
73
78
  }
74
79
  }