lazyclaw 5.4.1 → 5.4.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lazyclaw",
3
- "version": "5.4.1",
3
+ "version": "5.4.2",
4
4
  "description": "Lazy, elegant terminal CLI for chatting with Claude / OpenAI / Gemini / Ollama, orchestrating multi-step LLM workflows, and running multi-agent Slack teams with cross-task memory. Banner-on-launch, slash-command ghost autocomplete, persistent sessions, local HTTP gateway.",
5
5
  "keywords": [
6
6
  "claude",
package/tui/editor.mjs CHANGED
@@ -28,7 +28,7 @@
28
28
  // `width: '100%'` so Ink's wrap-ansi (already string-width aware) gets
29
29
  // the full terminal budget — fixes the perceived right-edge truncation
30
30
  // on long Korean buffers.
31
- import React, { useState, useEffect } from 'react';
31
+ import React, { useState, useEffect, useRef } from 'react';
32
32
  import { Box, Text, useInput } from 'ink';
33
33
  import stringWidth from 'string-width';
34
34
  import { theme } from './theme.mjs';
@@ -150,15 +150,31 @@ export function Editor({
150
150
  const [state, setState] = useState(() => makeEditorState({ history }));
151
151
  const slashOpen = Array.isArray(slashSuggestions) && slashSuggestions.length > 0;
152
152
 
153
+ // v5.4.2: keep a synchronous mirror of the editor state so back-to-back
154
+ // keystrokes don't lose characters to React's stale-closure problem.
155
+ // Korean / Japanese IME commits each completed syllable as a separate
156
+ // stdin event; if two events fire inside one React frame the second
157
+ // useInput call captures the pre-first-event `state` and overwrites
158
+ // the first event's setState payload — leaving the first character
159
+ // missing from `buffer`. Reading + writing through the ref means every
160
+ // applyKey() sees the latest buffer regardless of render timing.
161
+ const stateRef = useRef(state);
162
+ useEffect(() => { stateRef.current = state; }, [state]);
163
+ const commit = (next) => {
164
+ stateRef.current = next;
165
+ setState(next);
166
+ };
167
+
153
168
  useInput((input, key) => {
169
+ const current = stateRef.current;
154
170
  // ─── Slash-popup keyboard contract (highest priority when open) ──
155
171
  if (slashOpen) {
156
172
  // Esc: clear the buffer and dismiss the popup. The host's onEscape
157
173
  // is NOT called in this branch — Esc here is a popup gesture, not
158
174
  // a turn-abort. (Outside of popup mode Esc still aborts streaming.)
159
175
  if (key.escape) {
160
- const cleared = { ...state, buffer: '', cursor: 0, lastSubmit: null, lastWasPaste: false };
161
- setState(cleared);
176
+ const cleared = { ...current, buffer: '', cursor: 0, lastSubmit: null, lastWasPaste: false };
177
+ commit(cleared);
162
178
  if (onBufferChange) {
163
179
  try { onBufferChange(''); } catch {}
164
180
  }
@@ -184,15 +200,15 @@ export function Editor({
184
200
  if (key.tab || key.return) {
185
201
  const safeIdx = Math.max(0, Math.min(slashSuggestions.length - 1, slashSelectedIndex || 0));
186
202
  const picked = slashSuggestions[safeIdx];
187
- const bufTrim = state.buffer.replace(/\s+$/, '');
188
- const alreadyExact = !!picked && (state.buffer === picked.cmd || bufTrim === picked.cmd);
203
+ const bufTrim = current.buffer.replace(/\s+$/, '');
204
+ const alreadyExact = !!picked && (current.buffer === picked.cmd || bufTrim === picked.cmd);
189
205
  if (alreadyExact) {
190
206
  if (key.tab) return; // no completion to make
191
207
  // key.return on exact match → fall through to applyKey/submit.
192
208
  } else {
193
209
  if (picked) {
194
- const next = fillSlashCommand(state, picked.cmd);
195
- setState(next);
210
+ const next = fillSlashCommand(current, picked.cmd);
211
+ commit(next);
196
212
  if (onBufferChange) {
197
213
  try { onBufferChange(next.buffer); } catch {}
198
214
  }
@@ -206,8 +222,8 @@ export function Editor({
206
222
  // Esc: forward to host (ReplApp uses this to abort an in-flight turn).
207
223
  // Do not mutate the buffer — the user may want to keep typing.
208
224
  if (key.escape) { if (onEscape) onEscape(); return; }
209
- const next = applyKey(state, { input, key });
210
- setState(next);
225
+ const next = applyKey(current, { input, key });
226
+ commit(next);
211
227
  if (onBufferChange) {
212
228
  try { onBufferChange(next.buffer); } catch { /* observer is best-effort */ }
213
229
  }
package/tui/repl.mjs CHANGED
@@ -2,11 +2,15 @@
2
2
  // (spec §5.8) AND a sticky-bottom chat layout (v5.3).
3
3
  //
4
4
  // Layout (top → bottom inside the outer column):
5
- // 1. <Static items={scrollback}/> — splash item + per-turn user/assistant
6
- // blocks. Static renders each item ONCE to terminal scrollback and
7
- // never re-renders it, so the splash + history scroll away naturally
8
- // as new content appends. This is the Claude CLI / opencode pattern
9
- // translated to Ink's idiom Static IS the scroll buffer.
5
+ // 1. Scrollback — splash item + per-turn user/assistant blocks.
6
+ // Non-alt path uses <Static items={scrollback}/>: Ink writes each
7
+ // item ONCE to terminal scrollback so the splash + history scroll
8
+ // away naturally as new content appends (the v5.3 contract).
9
+ // Alt-buffer path renders the same items as regular flex children
10
+ // instead — Static's "write above the live frame" mechanism is
11
+ // invisible inside the DEC 1049 alt canvas (the live frame
12
+ // immediately overwrites that area), so v5.4.1 splashes vanished.
13
+ // Flex children re-render each frame; <Splash/> output is stable.
10
14
  // 2. Live region — partial assistant stream (state.liveAssistant) and
11
15
  // optional <SlashHints/> while the input buffer starts with '/'.
12
16
  // This Box re-renders on every chunk; the rest of the tree does not.
@@ -366,19 +370,23 @@ export function ReplApp({ splashProps, runTurn, runTurnFactory, slashCommands, o
366
370
  React.createElement(
367
371
  Box,
368
372
  { flexDirection: 'column', height: outerHeight },
369
- // 1) Scrollback (write-once via Ink Static).
370
- // In alt-buffer mode the Static lives inside a flex-grow inner
371
- // Box so it absorbs slack rather than pushing the editor out of
372
- // the viewport. In legacy mode it stays sibling-flat so the
373
- // structural test (Editor must be last sibling) still passes.
373
+ // 1) Scrollback.
374
+ // Alt-buffer path: render items as regular flex children, NOT via
375
+ // <Static/>. Ink's <Static/> writes once to stdout above the live
376
+ // frame — in the DEC 1049 alt canvas that area is immediately
377
+ // overwritten by the next live frame, so the splash + history
378
+ // end up invisible (v5.4.1 regression). Trade-off: items
379
+ // re-render each frame; <Splash/> output is stable so this is
380
+ // visually identical to the Static version.
381
+ // Non-alt path: keeps <Static/> — the legacy v5.3 contract
382
+ // (splash scrolls away naturally on the primary buffer) AND the
383
+ // structural snapshot in tests/v53-repl-layout.test.mjs.
374
384
  altEnabled
375
385
  ? React.createElement(
376
386
  Box,
377
387
  { flexDirection: 'column', flexGrow: 1, flexShrink: 1, overflow: 'hidden' },
378
- React.createElement(
379
- Static,
380
- { items: state.scrollback },
381
- (item) => React.createElement(ScrollbackItem, { key: item.id, item })
388
+ state.scrollback.map((item) =>
389
+ React.createElement(ScrollbackItem, { key: item.id, item })
382
390
  ),
383
391
  // Live region — partial assistant stream (inside the scroll
384
392
  // region so it grows naturally above the status bar).