klyro 1.0.12 → 1.0.14

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/cli/repl.js CHANGED
@@ -2689,7 +2689,7 @@ export async function startRepl(opts = {}) {
2689
2689
  ' Enter send · Shift+Enter newline · Tab complete slash · Esc drop queued / Esc×2 cancel run',
2690
2690
  ' Ctrl+C cancel (1st) / quit (2nd) · Ctrl+O expand last tool group · Ctrl+G jump bottom',
2691
2691
  ' PgUp/PgDn or Ctrl+U/Ctrl+D half-page · Ctrl+Home/End top/bottom · Home/End jump · Space jump to unread',
2692
- ' Ctrl+B/F page · Shift/Ctrl+↑/↓ line · ↑/↓ input history · PgUp/Dn scroll (KLYRO_MOUSE=1 adds wheel ±3 lines)',
2692
+ ' Ctrl+B/F page · Shift/Ctrl+↑/↓ line · ↑ history at live tail, scrolls while reading above · Ctrl+P/N history always · PgUp/Dn or Ctrl+U/D scroll (KLYRO_MOUSE=1 adds wheel ±3 lines)',
2693
2693
  ' Text selection/copy and right-click paste work natively · Shift+Enter newline · /vim toggles vim input mode · /keymap <note> saves a display note',
2694
2694
  ].join('\n'),
2695
2695
  });
package/dist/tui/app.js CHANGED
@@ -1052,24 +1052,34 @@ export function App(props) {
1052
1052
  void props.onSlash({ kind: 'quit' });
1053
1053
  return;
1054
1054
  }
1055
- // Contextual ↑/↓ (§8.3): history always wins when entries exist —
1056
- // empty input + ↑ recalls the last prompt (standard REPL behavior),
1057
- // typing filters by prefix is unnecessary so plain recall applies;
1058
- // the viewport scrolls only when there is no history to show.
1059
- if (key.upArrow && !key.shift && !key.ctrl) {
1055
+ // Contextual ↑/↓ (§8.3): both behaviors, split by where you are.
1056
+ // Scrolled up reading the transcript (not at the live tail, not already
1057
+ // browsing) → plain ↑ keeps scrolling so chat scroll is never lost.
1058
+ // At the live tail (or no scroll surface) → ↑ browses input history
1059
+ // newest-first, so send + ↑ restores the last prompt. Ctrl+P / Ctrl+N
1060
+ // are escape-free aliases for history prev/next: single-byte codes that
1061
+ // always browse, even on terminals that mangle arrow-key sequences.
1062
+ // ↓ is otherwise unchanged: browse newer while browsing, else scroll
1063
+ // toward the tail.
1064
+ const wantHistPrev = (key.upArrow && !key.shift && !key.ctrl) || (key.ctrl && inputStr === 'p');
1065
+ const wantHistNext = (key.downArrow && !key.shift && !key.ctrl) || (key.ctrl && inputStr === 'n');
1066
+ if (wantHistPrev) {
1067
+ if (!key.ctrl && isFullscreen && maxTop > 0 && !atBottom && histIdx === null && input.trim() === '') {
1068
+ commands.lineUp();
1069
+ return;
1070
+ }
1060
1071
  if (history.length > 0) {
1061
1072
  const next = histIdx === null ? history.length - 1 : Math.max(0, histIdx - 1);
1062
1073
  setHistIdx(next);
1063
1074
  setInput(history[next] ?? '');
1064
1075
  setVimCursor(null);
1065
- return;
1066
1076
  }
1067
- if (isFullscreen && maxTop > 0) {
1077
+ else if (!key.ctrl && isFullscreen && maxTop > 0) {
1068
1078
  commands.lineUp();
1069
- return;
1070
1079
  }
1080
+ return;
1071
1081
  }
1072
- if (key.downArrow && !key.shift && !key.ctrl) {
1082
+ if (wantHistNext) {
1073
1083
  if (histIdx !== null) {
1074
1084
  const next = histIdx + 1;
1075
1085
  if (next >= history.length) {
@@ -1083,8 +1093,8 @@ export function App(props) {
1083
1093
  setVimCursor(null);
1084
1094
  return;
1085
1095
  }
1086
- if (input.trim() !== '')
1087
- return; // single line with text, nothing newer
1096
+ if (input.trim() !== '' || key.ctrl)
1097
+ return; // text present, or Ctrl+N: nothing newer
1088
1098
  if (isFullscreen && maxTop > 0) {
1089
1099
  commands.lineDown();
1090
1100
  return;
@@ -257,29 +257,69 @@ describe('App', () => {
257
257
  await new Promise((r) => setTimeout(r, 30));
258
258
  expect(lastFrame() ?? '').toMatch(/MSG-00-tag/);
259
259
  });
260
- it('↑ on empty input recalls history newest-first; ↓ browses back (user report)', async () => {
260
+ it('↑ at live tail recalls history; scrolled up it scrolls (both kept)', async () => {
261
+ const onPrompt = vi.fn(async () => { });
262
+ const { stdin, lastFrame } = render(_jsx(App, { initialModel: "m", maxSteps: 10, cwd: "/test", onPrompt: onPrompt, onSlash: async () => { }, isFullscreen: true, initialTranscript: makeInitialTranscript(25) }));
263
+ await new Promise((r) => setTimeout(r, 50));
264
+ stdin.write('zzrecallme');
265
+ await new Promise((r) => setTimeout(r, 20));
266
+ stdin.write('\x0d');
267
+ await new Promise((r) => setTimeout(r, 50));
268
+ expect(onPrompt).toHaveBeenCalledWith('zzrecallme');
269
+ // At the live tail, empty input + ↑ recalls into the input line: typing
270
+ // '!' must extend the recalled text (only the input holds 'zzrecallme!').
271
+ stdin.write('\x1b[A');
272
+ await new Promise((r) => setTimeout(r, 30));
273
+ stdin.write('!');
274
+ await new Promise((r) => setTimeout(r, 30));
275
+ expect(lastFrame() ?? '').toContain('zzrecallme!');
276
+ });
277
+ it('↑ while scrolled up keeps scrolling and leaves input empty', async () => {
278
+ const { stdin, lastFrame } = render(_jsx(App, { initialModel: "m", maxSteps: 10, cwd: "/test", onPrompt: async () => { }, onSlash: async () => { }, isFullscreen: true, initialTranscript: makeInitialTranscript(25) }));
279
+ await new Promise((r) => setTimeout(r, 50));
280
+ stdin.write('zznoscroll');
281
+ await new Promise((r) => setTimeout(r, 20));
282
+ stdin.write('\x0d');
283
+ await new Promise((r) => setTimeout(r, 50));
284
+ stdin.write(KEY_HOME);
285
+ await new Promise((r) => setTimeout(r, 30));
286
+ // Scrolled up + empty input + ↑ → scroll (MSG-00 stays), input untouched:
287
+ // typing '!' must NOT extend the submitted prompt.
288
+ stdin.write('\x1b[A');
289
+ await new Promise((r) => setTimeout(r, 30));
290
+ expect(lastFrame() ?? '').toMatch(/MSG-00-tag/);
291
+ stdin.write('!');
292
+ await new Promise((r) => setTimeout(r, 30));
293
+ expect(lastFrame() ?? '').not.toContain('zznoscroll!');
294
+ });
295
+ it('Ctrl+P / Ctrl+N browse history without arrow keys', async () => {
261
296
  const onPrompt = vi.fn(async () => { });
262
297
  const { stdin, lastFrame } = render(_jsx(App, { initialModel: "m", maxSteps: 10, cwd: "/test", onPrompt: onPrompt, onSlash: async () => { } }));
263
- stdin.write('hi');
298
+ stdin.write('zzfirst');
264
299
  await new Promise((r) => setTimeout(r, 20));
265
300
  stdin.write('\x0d');
266
301
  await new Promise((r) => setTimeout(r, 50));
267
- stdin.write('second prompt');
302
+ stdin.write('zzsecond');
268
303
  await new Promise((r) => setTimeout(r, 20));
269
304
  stdin.write('\x0d');
270
305
  await new Promise((r) => setTimeout(r, 50));
271
306
  expect(onPrompt).toHaveBeenCalledTimes(2);
272
- // Input is empty after each submit; plain ↑ must recall, not scroll.
273
- stdin.write('\x1b[A');
307
+ // Ctrl+P recalls newest-first with zero escape sequences involved.
308
+ stdin.write('\x10');
274
309
  await new Promise((r) => setTimeout(r, 30));
275
- expect(lastFrame() ?? '').toContain('second prompt');
276
- stdin.write('\x1b[A');
310
+ stdin.write('!');
311
+ await new Promise((r) => setTimeout(r, 30));
312
+ expect(lastFrame() ?? '').toContain('zzsecond!');
313
+ // Ctrl+P twice walks older with no typing between, Ctrl+N walks newer.
314
+ stdin.write('\x10');
315
+ await new Promise((r) => setTimeout(r, 30));
316
+ stdin.write('\x10');
317
+ await new Promise((r) => setTimeout(r, 30));
318
+ stdin.write('\x0e');
277
319
  await new Promise((r) => setTimeout(r, 30));
278
- expect(lastFrame() ?? '').toContain('hi');
279
- // ↓ walks back toward newer entries.
280
- stdin.write('\x1b[B');
320
+ stdin.write('#');
281
321
  await new Promise((r) => setTimeout(r, 30));
282
- expect(lastFrame() ?? '').toContain('second prompt');
322
+ expect(lastFrame() ?? '').toContain('zzsecond#');
283
323
  });
284
324
  it('/c shows top-6 suggestions and Tab completes', async () => {
285
325
  const { stdin, lastFrame } = render(_jsx(App, { initialModel: "m", maxSteps: 10, cwd: "/test", onPrompt: async () => { }, onSlash: async () => { } }));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "klyro",
3
- "version": "1.0.12",
3
+ "version": "1.0.14",
4
4
  "description": "Klyro — autonomous coding harness CLI that streams from any OpenAI-compatible or Anthropic LLM endpoint.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",