@pellux/goodvibes-tui 0.19.94 → 0.19.96

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/CHANGELOG.md CHANGED
@@ -4,6 +4,16 @@ All notable changes to GoodVibes TUI.
4
4
 
5
5
  ---
6
6
 
7
+ ## [0.19.96] — 2026-05-11
8
+
9
+ ### Changes
10
+ - 77abb7be fix: navigate multiline prompt history
11
+
12
+ ## [0.19.95] — 2026-05-11
13
+
14
+ ### Changes
15
+ - e04ad422 fix: preserve first typed key after recovery prompt
16
+
7
17
  ## [0.19.94] — 2026-05-11
8
18
 
9
19
  ### Changes
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  [![CI](https://github.com/mgd34msu/goodvibes-tui/actions/workflows/ci.yml/badge.svg)](https://github.com/mgd34msu/goodvibes-tui/actions/workflows/ci.yml)
4
4
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
5
- [![Version](https://img.shields.io/badge/version-0.19.94-blue.svg)](https://github.com/mgd34msu/goodvibes-tui)
5
+ [![Version](https://img.shields.io/badge/version-0.19.96-blue.svg)](https://github.com/mgd34msu/goodvibes-tui)
6
6
 
7
7
  A terminal-native AI coding, operations, automation, knowledge, and integration console with a typed runtime, omnichannel surfaces, structured memory/knowledge, and a raw ANSI renderer.
8
8
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pellux/goodvibes-tui",
3
- "version": "0.19.94",
3
+ "version": "0.19.96",
4
4
  "description": "Terminal-native GoodVibes product for coding, operations, automation, knowledge, channels, and daemon-backed control-plane workflows.",
5
5
  "type": "module",
6
6
  "main": "src/main.ts",
@@ -56,6 +56,7 @@ import type { PanelMouseLayout } from './handler-feed-routes.ts';
56
56
  export interface FeedContextMutableInit {
57
57
  prompt: string;
58
58
  cursorPos: number;
59
+ inputScrollTop: number;
59
60
  commandMode: boolean;
60
61
  panelFocused: boolean;
61
62
  indicatorFocused: boolean;
@@ -230,6 +231,7 @@ export function syncFeedContextMutableFields(
230
231
  ): void {
231
232
  ctx.prompt = fields.prompt;
232
233
  ctx.cursorPos = fields.cursorPos;
234
+ ctx.inputScrollTop = fields.inputScrollTop;
233
235
  ctx.commandMode = fields.commandMode;
234
236
  ctx.panelFocused = fields.panelFocused;
235
237
  ctx.indicatorFocused = fields.indicatorFocused;
@@ -6,6 +6,11 @@ import type { CommandRegistry, CommandContext } from './command-registry.ts';
6
6
  import type { AutocompleteEngine } from './autocomplete.ts';
7
7
  import type { SelectionManager } from './selection.ts';
8
8
  import type { WrappedPromptInfo } from './handler-prompt-buffer.ts';
9
+ import {
10
+ ensureInputCursorVisible as computeInputScrollTop,
11
+ getWrappedPromptInfo as computeWrappedPromptInfo,
12
+ moveCursorVertical as computeCursorVerticalMove,
13
+ } from './handler-prompt-buffer.ts';
9
14
  import { cleanupMarkerRegistry, expandPrompt, findMarkerAtPos, registerPaste } from './handler-content-actions.ts';
10
15
  import type { PanelManager } from '../panels/panel-manager.ts';
11
16
  import type { KeybindingsManager } from './keybindings.ts';
@@ -243,8 +248,10 @@ export function handlePromptTextToken(state: TextRouteState, token: InputToken):
243
248
  export type KeyRouteState = {
244
249
  prompt: string;
245
250
  cursorPos: number;
251
+ inputScrollTop: number;
246
252
  commandMode: boolean;
247
253
  contentWidth: number;
254
+ maxInputRows: number;
248
255
  inputHistory: InputHistory | null;
249
256
  indicatorFocused: boolean;
250
257
  conversationManager: ConversationManager | null;
@@ -271,6 +278,7 @@ export function handlePromptKeyToken(state: KeyRouteState, token: InputToken): {
271
278
  handled: boolean;
272
279
  prompt: string;
273
280
  cursorPos: number;
281
+ inputScrollTop: number;
274
282
  commandMode: boolean;
275
283
  indicatorFocused: boolean;
276
284
  } {
@@ -279,6 +287,7 @@ export function handlePromptKeyToken(state: KeyRouteState, token: InputToken): {
279
287
  handled: false,
280
288
  prompt: state.prompt,
281
289
  cursorPos: state.cursorPos,
290
+ inputScrollTop: state.inputScrollTop,
282
291
  commandMode: state.commandMode,
283
292
  indicatorFocused: state.indicatorFocused,
284
293
  };
@@ -286,8 +295,12 @@ export function handlePromptKeyToken(state: KeyRouteState, token: InputToken): {
286
295
 
287
296
  let prompt = state.prompt;
288
297
  let cursorPos = state.cursorPos;
298
+ let inputScrollTop = state.inputScrollTop;
289
299
  let commandMode = state.commandMode;
290
300
  let indicatorFocused = state.indicatorFocused;
301
+ const ensureLocalInputCursorVisible = () => {
302
+ inputScrollTop = computeInputScrollTop(prompt, cursorPos, inputScrollTop, state.contentWidth, state.maxInputRows);
303
+ };
291
304
  const runQuitShortcut = (commandName: 'quit' | 'wq') => {
292
305
  if (state.commandContext?.executeCommand) {
293
306
  void state.commandContext.executeCommand(commandName, []).catch((error) => {
@@ -304,15 +317,15 @@ export function handlePromptKeyToken(state: KeyRouteState, token: InputToken): {
304
317
  if (!state.handlePathCompletion()) {
305
318
  state.handleBlockToggle();
306
319
  }
307
- return { handled: true, prompt, cursorPos, commandMode, indicatorFocused };
320
+ return { handled: true, prompt, cursorPos, inputScrollTop, commandMode, indicatorFocused };
308
321
  }
309
322
 
310
323
  if (token.logicalName === 'enter') {
311
324
  if (token.shift) {
312
325
  prompt = prompt.slice(0, cursorPos) + '\n' + prompt.slice(cursorPos);
313
326
  cursorPos++;
314
- state.ensureInputCursorVisible();
315
- return { handled: true, prompt, cursorPos, commandMode, indicatorFocused };
327
+ ensureLocalInputCursorVisible();
328
+ return { handled: true, prompt, cursorPos, inputScrollTop, commandMode, indicatorFocused };
316
329
  }
317
330
 
318
331
  const text = prompt.trim();
@@ -323,7 +336,7 @@ export function handlePromptKeyToken(state: KeyRouteState, token: InputToken): {
323
336
  state.modalOpened('blockActions');
324
337
  state.blockActionsMenu.open(nearest);
325
338
  state.requestRender();
326
- return { handled: true, prompt, cursorPos, commandMode, indicatorFocused };
339
+ return { handled: true, prompt, cursorPos, inputScrollTop, commandMode, indicatorFocused };
327
340
  }
328
341
  }
329
342
  if (text === ':q' || text === ':wq') {
@@ -331,7 +344,7 @@ export function handlePromptKeyToken(state: KeyRouteState, token: InputToken): {
331
344
  cursorPos = 0;
332
345
  runQuitShortcut(text === ':wq' ? 'wq' : 'quit');
333
346
  state.requestRender();
334
- return { handled: true, prompt, cursorPos, commandMode, indicatorFocused };
347
+ return { handled: true, prompt, cursorPos, inputScrollTop, commandMode, indicatorFocused };
335
348
  }
336
349
  if (text) {
337
350
  const expanded = state.expandPrompt(text);
@@ -354,7 +367,7 @@ export function handlePromptKeyToken(state: KeyRouteState, token: InputToken): {
354
367
  state.commandContext?.submitInput?.(textOnly, expanded);
355
368
  }
356
369
  }
357
- return { handled: true, prompt, cursorPos, commandMode, indicatorFocused };
370
+ return { handled: true, prompt, cursorPos, inputScrollTop, commandMode, indicatorFocused };
358
371
  }
359
372
 
360
373
  if (token.logicalName === 'backspace') {
@@ -376,9 +389,9 @@ export function handlePromptKeyToken(state: KeyRouteState, token: InputToken): {
376
389
  prompt = prompt.slice(0, cursorPos - 1) + prompt.slice(cursorPos);
377
390
  cursorPos--;
378
391
  }
379
- state.ensureInputCursorVisible(state.contentWidth);
392
+ ensureLocalInputCursorVisible();
380
393
  }
381
- return { handled: true, prompt, cursorPos, commandMode, indicatorFocused };
394
+ return { handled: true, prompt, cursorPos, inputScrollTop, commandMode, indicatorFocused };
382
395
  }
383
396
 
384
397
  if (token.logicalName === 'delete') {
@@ -392,52 +405,58 @@ export function handlePromptKeyToken(state: KeyRouteState, token: InputToken): {
392
405
  } else {
393
406
  prompt = prompt.slice(0, cursorPos) + prompt.slice(cursorPos + 1);
394
407
  }
395
- state.ensureInputCursorVisible(state.contentWidth);
408
+ ensureLocalInputCursorVisible();
396
409
  }
397
410
  state.requestRender();
398
- return { handled: true, prompt, cursorPos, commandMode, indicatorFocused };
411
+ return { handled: true, prompt, cursorPos, inputScrollTop, commandMode, indicatorFocused };
399
412
  }
400
413
 
401
414
  if (token.logicalName === 'left') {
402
415
  if (cursorPos > 0) {
403
416
  const marker = state.findMarkerAtPos(cursorPos);
404
417
  cursorPos = marker ? marker.start : cursorPos - 1;
405
- state.ensureInputCursorVisible(state.contentWidth);
418
+ ensureLocalInputCursorVisible();
406
419
  }
407
420
  state.requestRender();
408
- return { handled: true, prompt, cursorPos, commandMode, indicatorFocused };
421
+ return { handled: true, prompt, cursorPos, inputScrollTop, commandMode, indicatorFocused };
409
422
  }
410
423
 
411
424
  if (token.logicalName === 'right') {
412
425
  if (cursorPos < prompt.length) {
413
426
  const marker = state.findMarkerAtPos(cursorPos + 1);
414
427
  cursorPos = marker ? marker.end : cursorPos + 1;
415
- state.ensureInputCursorVisible(state.contentWidth);
428
+ ensureLocalInputCursorVisible();
416
429
  }
417
430
  state.requestRender();
418
- return { handled: true, prompt, cursorPos, commandMode, indicatorFocused };
431
+ return { handled: true, prompt, cursorPos, inputScrollTop, commandMode, indicatorFocused };
419
432
  }
420
433
 
421
434
  if (token.logicalName === 'home') {
422
435
  cursorPos = 0;
423
- return { handled: true, prompt, cursorPos, commandMode, indicatorFocused };
436
+ ensureLocalInputCursorVisible();
437
+ return { handled: true, prompt, cursorPos, inputScrollTop, commandMode, indicatorFocused };
424
438
  }
425
439
 
426
440
  if (token.logicalName === 'end') {
427
441
  cursorPos = prompt.length;
428
- return { handled: true, prompt, cursorPos, commandMode, indicatorFocused };
442
+ ensureLocalInputCursorVisible();
443
+ return { handled: true, prompt, cursorPos, inputScrollTop, commandMode, indicatorFocused };
429
444
  }
430
445
 
431
446
  if (token.logicalName === 'up') {
432
- if (!state.moveCursorVertical(-1)) {
433
- const info = state.getWrappedPromptInfo(state.contentWidth);
447
+ const move = computeCursorVerticalMove(prompt, cursorPos, inputScrollTop, state.contentWidth, state.maxInputRows, -1);
448
+ if (move.moved) {
449
+ cursorPos = move.cursorPos;
450
+ inputScrollTop = move.inputScrollTop;
451
+ } else {
452
+ const info = computeWrappedPromptInfo(prompt, cursorPos, inputScrollTop, state.contentWidth, state.maxInputRows);
434
453
  if (info.cursorWrappedLine === 0) {
435
454
  if (state.inputHistory) {
436
455
  const recalled = state.inputHistory.up(prompt);
437
456
  if (recalled !== null) {
438
457
  prompt = recalled;
439
458
  cursorPos = recalled.length;
440
- state.ensureInputCursorVisible();
459
+ ensureLocalInputCursorVisible();
441
460
  } else {
442
461
  state.scroll(-3);
443
462
  }
@@ -446,22 +465,23 @@ export function handlePromptKeyToken(state: KeyRouteState, token: InputToken): {
446
465
  }
447
466
  }
448
467
  }
449
- return { handled: true, prompt, cursorPos, commandMode, indicatorFocused };
468
+ return { handled: true, prompt, cursorPos, inputScrollTop, commandMode, indicatorFocused };
450
469
  }
451
470
 
452
471
  if (token.logicalName === 'down') {
453
- if (!state.moveCursorVertical(1)) {
454
- const info = state.getWrappedPromptInfo(state.contentWidth);
455
- if (info.wrappedLines.length <= 1) {
456
- if (state.inputHistory?.isBrowsing) {
457
- const recalled = state.inputHistory.down();
458
- if (recalled !== null) {
459
- prompt = recalled;
460
- cursorPos = recalled.length;
461
- state.ensureInputCursorVisible();
462
- } else {
463
- indicatorFocused = true;
464
- }
472
+ const move = computeCursorVerticalMove(prompt, cursorPos, inputScrollTop, state.contentWidth, state.maxInputRows, 1);
473
+ if (move.moved) {
474
+ cursorPos = move.cursorPos;
475
+ inputScrollTop = move.inputScrollTop;
476
+ } else {
477
+ const info = computeWrappedPromptInfo(prompt, cursorPos, inputScrollTop, state.contentWidth, state.maxInputRows);
478
+ const atBottom = info.cursorWrappedLine >= info.wrappedLines.length - 1;
479
+ if (atBottom && state.inputHistory?.isBrowsing) {
480
+ const recalled = state.inputHistory.down();
481
+ if (recalled !== null) {
482
+ prompt = recalled;
483
+ cursorPos = recalled.length;
484
+ ensureLocalInputCursorVisible();
465
485
  } else {
466
486
  indicatorFocused = true;
467
487
  }
@@ -469,17 +489,17 @@ export function handlePromptKeyToken(state: KeyRouteState, token: InputToken): {
469
489
  indicatorFocused = true;
470
490
  }
471
491
  }
472
- return { handled: true, prompt, cursorPos, commandMode, indicatorFocused };
492
+ return { handled: true, prompt, cursorPos, inputScrollTop, commandMode, indicatorFocused };
473
493
  }
474
494
 
475
495
  if (token.logicalName === 'f2') {
476
496
  indicatorFocused = false;
477
497
  state.modalOpened('process');
478
498
  state.processModal.open();
479
- return { handled: true, prompt, cursorPos, commandMode, indicatorFocused };
499
+ return { handled: true, prompt, cursorPos, inputScrollTop, commandMode, indicatorFocused };
480
500
  }
481
501
 
482
- return { handled: false, prompt, cursorPos, commandMode, indicatorFocused };
502
+ return { handled: false, prompt, cursorPos, inputScrollTop, commandMode, indicatorFocused };
483
503
  }
484
504
 
485
505
  export type MouseRouteState = {
@@ -85,6 +85,7 @@ import type { ModelPickerTarget } from './model-picker.ts';
85
85
  export interface InputFeedContext {
86
86
  prompt: string;
87
87
  cursorPos: number;
88
+ inputScrollTop: number;
88
89
  commandMode: boolean;
89
90
  panelFocused: boolean;
90
91
  indicatorFocused: boolean;
@@ -365,8 +366,10 @@ export function feedInputTokens(context: InputFeedContext, tokens: readonly Inpu
365
366
  const keyRoute = handlePromptKeyToken({
366
367
  prompt: context.prompt,
367
368
  cursorPos: context.cursorPos,
369
+ inputScrollTop: context.inputScrollTop,
368
370
  commandMode: context.commandMode,
369
371
  contentWidth: context.contentWidth,
372
+ maxInputRows: 8,
370
373
  inputHistory: context.inputHistory,
371
374
  indicatorFocused: context.indicatorFocused,
372
375
  conversationManager: context.conversationManager,
@@ -391,6 +394,7 @@ export function feedInputTokens(context: InputFeedContext, tokens: readonly Inpu
391
394
  if (keyRoute.handled) {
392
395
  context.prompt = keyRoute.prompt;
393
396
  context.cursorPos = keyRoute.cursorPos;
397
+ context.inputScrollTop = keyRoute.inputScrollTop;
394
398
  context.commandMode = keyRoute.commandMode;
395
399
  context.indicatorFocused = keyRoute.indicatorFocused;
396
400
  continue;
@@ -258,7 +258,7 @@ export class InputHandler {
258
258
  public initFeedContext(): void {
259
259
  this.feedContext = buildInitialFeedContext(
260
260
  {
261
- prompt: this.prompt, cursorPos: this.cursorPos, commandMode: this.commandMode,
261
+ prompt: this.prompt, cursorPos: this.cursorPos, inputScrollTop: this.inputScrollTop, commandMode: this.commandMode,
262
262
  panelFocused: this.panelFocused, indicatorFocused: this.indicatorFocused,
263
263
  helpOverlayActive: this.helpOverlayActive, helpScrollOffset: this.helpScrollOffset,
264
264
  shortcutsOverlayActive: this.shortcutsOverlayActive, shortcutsScrollOffset: this.shortcutsScrollOffset,
@@ -338,7 +338,7 @@ export class InputHandler {
338
338
  /** Sync mutable handler fields back into feedContext after in-feed mutations. */
339
339
  public syncFeedContextMutableFields(): void {
340
340
  const h = this;
341
- syncFeedContextMutableFields({ prompt: h.prompt, cursorPos: h.cursorPos, commandMode: h.commandMode,
341
+ syncFeedContextMutableFields({ prompt: h.prompt, cursorPos: h.cursorPos, inputScrollTop: h.inputScrollTop, commandMode: h.commandMode,
342
342
  panelFocused: h.panelFocused, indicatorFocused: h.indicatorFocused, helpOverlayActive: h.helpOverlayActive,
343
343
  helpScrollOffset: h.helpScrollOffset, shortcutsOverlayActive: h.shortcutsOverlayActive,
344
344
  shortcutsScrollOffset: h.shortcutsScrollOffset, selectionCallback: h.selectionCallback,
@@ -445,6 +445,7 @@ export class InputHandler {
445
445
  // Sync mutable scalars from handler into the reused context.
446
446
  context.prompt = this.prompt;
447
447
  context.cursorPos = this.cursorPos;
448
+ context.inputScrollTop = this.inputScrollTop;
448
449
  context.commandMode = this.commandMode;
449
450
  context.panelFocused = this.panelFocused;
450
451
  context.indicatorFocused = this.indicatorFocused;
@@ -473,6 +474,7 @@ export class InputHandler {
473
474
  feedInputTokens(context, this.tokenizer.feed(data));
474
475
  this.prompt = context.prompt;
475
476
  this.cursorPos = context.cursorPos;
477
+ this.inputScrollTop = context.inputScrollTop;
476
478
  this.commandMode = context.commandMode;
477
479
  this.panelFocused = context.panelFocused;
478
480
  this.indicatorFocused = context.indicatorFocused;
@@ -175,7 +175,6 @@ export class InputHistory {
175
175
  * Navigate up (older entry).
176
176
  * On first call, saves currentInput as draft.
177
177
  * Returns the entry to display, or null if at boundary.
178
- * Only single-line entries are returned (multiline stored but skipped).
179
178
  */
180
179
  up(currentInput: string): string | null {
181
180
  if (this.entries.length === 0) return null;
@@ -185,15 +184,10 @@ export class InputHistory {
185
184
  this.draft = currentInput;
186
185
  }
187
186
 
188
- // Try to advance to an older single-line entry
189
- let next = this.position + 1;
190
- while (next < this.entries.length) {
191
- const entry = this.entries[next]!;
192
- if (!this.getDisplayText(entry).includes('\n')) {
193
- this.position = next;
194
- return this.getRecallText(this.entries[this.position]!);
195
- }
196
- next++;
187
+ const next = this.position + 1;
188
+ if (next < this.entries.length) {
189
+ this.position = next;
190
+ return this.getRecallText(this.entries[this.position]!);
197
191
  }
198
192
 
199
193
  // At oldest boundary
@@ -208,15 +202,10 @@ export class InputHistory {
208
202
  down(): string | null {
209
203
  if (this.position === -1) return null;
210
204
 
211
- // Try to find a newer single-line entry
212
- let prev = this.position - 1;
213
- while (prev >= 0) {
214
- const entry = this.entries[prev]!;
215
- if (!this.getDisplayText(entry).includes('\n')) {
216
- this.position = prev;
217
- return this.getRecallText(this.entries[this.position]!);
218
- }
219
- prev--;
205
+ const prev = this.position - 1;
206
+ if (prev >= 0) {
207
+ this.position = prev;
208
+ return this.getRecallText(this.entries[this.position]!);
220
209
  }
221
210
 
222
211
  // Back to draft
package/src/main.ts CHANGED
@@ -766,7 +766,7 @@ async function main() {
766
766
  // --- Crash recovery check ---
767
767
  const recoveryInfo = checkRecoveryFile({ workingDirectory: workingDir, homeDirectory });
768
768
  if (recoveryInfo) {
769
- systemMessageRouter.high(`[Recovery] Found unsaved session from ${new Date(recoveryInfo.timestamp).toLocaleString()}. Title: "${recoveryInfo.title}". Press R to restore, any other key to discard.`);
769
+ systemMessageRouter.high(`[Recovery] Found unsaved session from ${new Date(recoveryInfo.timestamp).toLocaleString()}. Title: "${recoveryInfo.title}". Press Ctrl+R to restore, Esc to discard, or start typing to ignore it.`);
770
770
  for (const line of formatReturnContextForDisplay(recoveryInfo.returnContext)) {
771
771
  systemMessageRouter.low(`[Recovery] ${line}`);
772
772
  }
@@ -68,8 +68,7 @@ export function handleBlockingShellInput(
68
68
  }
69
69
 
70
70
  if (recoveryPending) {
71
- const key = data.toLowerCase();
72
- if (key === 'r') {
71
+ if (data === '\x12') {
73
72
  const recovery = loadRecoveryConversation();
74
73
  if (recovery) {
75
74
  conversation.fromJSON({ messages: recovery.messages as Parameters<typeof conversation.fromJSON>[0]['messages'] });
@@ -77,12 +76,22 @@ export function handleBlockingShellInput(
77
76
  } else {
78
77
  systemMessageRouter.high('[Recovery] Failed to restore saved data.');
79
78
  }
80
- } else {
79
+ deleteRecoveryFile();
80
+ render();
81
+ return { handled: true, pendingPermission: null, recoveryPending: false };
82
+ }
83
+
84
+ if (data === '\x1b' || data === '\x03') {
81
85
  systemMessageRouter.high('[Recovery] Discarded recovery data.');
86
+ deleteRecoveryFile();
87
+ render();
88
+ return { handled: true, pendingPermission: null, recoveryPending: false };
82
89
  }
90
+
91
+ systemMessageRouter.high('[Recovery] Ignored saved session; starting a new prompt.');
83
92
  deleteRecoveryFile();
84
93
  render();
85
- return { handled: true, pendingPermission: null, recoveryPending: false };
94
+ return { handled: false, pendingPermission: null, recoveryPending: false };
86
95
  }
87
96
 
88
97
  return { handled: false, pendingPermission, recoveryPending };
package/src/version.ts CHANGED
@@ -6,7 +6,7 @@ import { join } from 'node:path';
6
6
  // The prebuild script updates the fallback value before compilation.
7
7
  // Uses import.meta.dir (Bun) to locate package.json relative to this file,
8
8
  // which is correct regardless of the process working directory.
9
- let _version = '0.19.94';
9
+ let _version = '0.19.96';
10
10
  try {
11
11
  const pkg = JSON.parse(readFileSync(join(import.meta.dir, '..', 'package.json'), 'utf-8'));
12
12
  _version = pkg.version ?? _version;