@pellux/goodvibes-tui 0.19.95 → 0.19.98

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,21 @@ All notable changes to GoodVibes TUI.
4
4
 
5
5
  ---
6
6
 
7
+ ## [0.19.98] — 2026-05-11
8
+
9
+ ### Changes
10
+ - 6fa4c84b fix: pass owned project root to command paste
11
+
12
+ ## [0.19.97] — 2026-05-11
13
+
14
+ ### Changes
15
+ - 256454c2 fix: support explicit clipboard image paste
16
+
17
+ ## [0.19.96] — 2026-05-11
18
+
19
+ ### Changes
20
+ - 77abb7be fix: navigate multiline prompt history
21
+
7
22
  ## [0.19.95] — 2026-05-11
8
23
 
9
24
  ### 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.95-blue.svg)](https://github.com/mgd34msu/goodvibes-tui)
5
+ [![Version](https://img.shields.io/badge/version-0.19.98-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
 
@@ -1278,6 +1278,7 @@ Those pieces cover conversation-noise routing, panel-health/performance budgets,
1278
1278
  | `/keybindings` | `/kb` | List current keyboard bindings and their config file path |
1279
1279
  | `/schedule [action]` | `/sched` | Manage scheduled agent tasks (cron): add, list, remove, enable, disable, run |
1280
1280
  | `/image <path>` | `/img` | Attach an image file to the next message |
1281
+ | `/paste` | `/clip` | Pull supported text or image data directly from the system clipboard into the prompt |
1281
1282
  | `/refresh-models` | — | Refresh model catalog, benchmarks, and token limits |
1282
1283
  | `/notify [action]` | `/ntf` | Manage webhook notifications (ntfy.sh): add, remove, list, clear, test |
1283
1284
  | `/voice [action]` | — | Review optional voice posture and export/inspect voice bundles |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pellux/goodvibes-tui",
3
- "version": "0.19.95",
3
+ "version": "0.19.98",
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",
@@ -61,6 +61,11 @@ export interface CommandUiActions {
61
61
  submitInput?: (text: string, content?: import('@pellux/goodvibes-sdk/platform/providers').ContentPart[]) => void;
62
62
  submitSpokenInput?: (text: string, content?: import('@pellux/goodvibes-sdk/platform/providers').ContentPart[]) => void;
63
63
  stopSpokenOutput?: () => void;
64
+ pasteFromClipboard?: () => {
65
+ pasted: boolean;
66
+ kind: 'image' | 'text' | 'none';
67
+ marker?: string;
68
+ };
64
69
  executeCommand?: (name: string, args: string[]) => Promise<boolean>;
65
70
  cancelGeneration?: () => void;
66
71
  completeModelSelection?: (selection: {
@@ -91,6 +91,22 @@ export function registerShellCoreCommands(registry: CommandRegistry): void {
91
91
  },
92
92
  });
93
93
 
94
+ registry.register({
95
+ name: 'paste',
96
+ aliases: ['clip'],
97
+ description: 'Insert clipboard text or image into the prompt',
98
+ handler(_args, ctx) {
99
+ if (!ctx.pasteFromClipboard) {
100
+ ctx.print('Paste is not available in this context.');
101
+ return;
102
+ }
103
+ const result = ctx.pasteFromClipboard();
104
+ if (!result.pasted) {
105
+ ctx.print('Clipboard does not contain supported text or image data.');
106
+ }
107
+ },
108
+ });
109
+
94
110
  registry.register({
95
111
  name: 'help',
96
112
  aliases: ['h', '?'],
@@ -135,6 +151,7 @@ export function registerShellCoreCommands(registry: CommandRegistry): void {
135
151
  { id: '/template save', label: '/template save <name>', detail: 'Save prompt as template', category: 'Templates' },
136
152
  { id: '/template use', label: '/template use <name>', detail: 'Execute template', category: 'Templates' },
137
153
  { id: '/tools', label: '/tools', detail: 'List available tools', category: 'Tools & System' },
154
+ { id: '/paste', label: '/paste', detail: 'Insert clipboard text or image into the prompt', category: 'Tools & System' },
138
155
  { id: '/shortcuts', label: '/shortcuts', detail: 'View keyboard shortcuts reference', category: 'Tools & System' },
139
156
  { id: '/commands', label: '/commands', detail: 'Browse all commands in a scrollable list', category: 'Tools & System' },
140
157
  { id: '/secrets', label: '/secrets set|link|get|test|list|delete', detail: 'Manage encrypted and provider-backed secrets', category: 'Tools & System' },
@@ -154,7 +171,7 @@ export function registerShellCoreCommands(registry: CommandRegistry): void {
154
171
  });
155
172
  return;
156
173
  }
157
- ctx.print('Use /help to open the help modal. Commands: /model, /provider, /config, /template, /tools, /sessions, /bookmarks, /save, /load, /undo, /redo, /retry, /clear, /reset, /compact, /export, /title, /effort, /expand, /collapse, /debug, /quit, /wq');
174
+ ctx.print('Use /help to open the help modal. Commands: /model, /provider, /config, /template, /tools, /paste, /sessions, /bookmarks, /save, /load, /undo, /redo, /retry, /clear, /reset, /compact, /export, /title, /effort, /expand, /collapse, /debug, /quit, /wq');
158
175
  },
159
176
  });
160
177
 
@@ -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;
@@ -97,6 +98,7 @@ export interface FeedContextStableRefs {
97
98
  selection: SelectionManager;
98
99
  pasteRegistry: Map<string, string>;
99
100
  imageRegistry: Map<string, { data: string; mediaType: string }>;
101
+ projectRoot: string;
100
102
  selectionModal: SelectionModal;
101
103
  bookmarkModal: BookmarkModal;
102
104
  settingsModal: SettingsModal;
@@ -230,6 +232,7 @@ export function syncFeedContextMutableFields(
230
232
  ): void {
231
233
  ctx.prompt = fields.prompt;
232
234
  ctx.cursorPos = fields.cursorPos;
235
+ ctx.inputScrollTop = fields.inputScrollTop;
233
236
  ctx.commandMode = fields.commandMode;
234
237
  ctx.panelFocused = fields.panelFocused;
235
238
  ctx.indicatorFocused = fields.indicatorFocused;
@@ -4,6 +4,7 @@ import type { AutocompleteEngine } from './autocomplete.ts';
4
4
  import type { InputToken } from '@pellux/goodvibes-sdk/platform/core';
5
5
  import type { ConversationManager } from '../core/conversation';
6
6
  import type { PanelManager } from '../panels/panel-manager.ts';
7
+ import { handleClipboardPaste, type ClipboardPasteSource } from './handler-content-actions.ts';
7
8
 
8
9
  export type CommandModeRouteState = {
9
10
  commandMode: boolean;
@@ -18,6 +19,14 @@ export type CommandModeRouteState = {
18
19
  conversationManager: ConversationManager | null;
19
20
  requestRender: () => void;
20
21
  handleEscape: () => void;
22
+ projectRoot: string;
23
+ pasteRegistry: Map<string, string>;
24
+ imageRegistry: Map<string, { data: string; mediaType: string }>;
25
+ nextPasteId: number;
26
+ nextImageId: number;
27
+ saveUndoState: () => void;
28
+ ensureInputCursorVisible: () => void;
29
+ clipboard?: ClipboardPasteSource;
21
30
  };
22
31
 
23
32
  export function handleCommandModeToken(state: CommandModeRouteState, token: InputToken): boolean {
@@ -141,6 +150,24 @@ function withPanelFocusSync(context: CommandContext, state: CommandModeRouteStat
141
150
  state.panelFocused = false;
142
151
  }
143
152
  : undefined,
153
+ pasteFromClipboard: () => {
154
+ const result = handleClipboardPaste({
155
+ prompt: state.prompt,
156
+ cursorPos: state.cursorPos,
157
+ pasteRegistry: state.pasteRegistry,
158
+ nextPasteId: state.nextPasteId,
159
+ imageRegistry: state.imageRegistry,
160
+ nextImageId: state.nextImageId,
161
+ saveUndoState: state.saveUndoState,
162
+ ensureInputCursorVisible: state.ensureInputCursorVisible,
163
+ requestRender: state.requestRender,
164
+ }, context.workspace.shellPaths?.workingDirectory ?? state.projectRoot, state.clipboard);
165
+ state.prompt = result.prompt;
166
+ state.cursorPos = result.cursorPos;
167
+ state.nextImageId = result.nextImageId;
168
+ state.nextPasteId = result.nextPasteId;
169
+ return result;
170
+ },
144
171
  executeCommand: async (name, args) => {
145
172
  const wrapped = withPanelFocusSync(context, state);
146
173
  const handled = state.commandRegistry?.get(name)
@@ -56,6 +56,23 @@ export type PasteRegistryState = {
56
56
  nextImageId: number;
57
57
  };
58
58
 
59
+ export type ClipboardPasteKind = 'image' | 'text' | 'none';
60
+
61
+ export interface ClipboardPasteResult {
62
+ prompt: string;
63
+ cursorPos: number;
64
+ nextImageId: number;
65
+ nextPasteId: number;
66
+ pasted: boolean;
67
+ kind: ClipboardPasteKind;
68
+ marker?: string;
69
+ }
70
+
71
+ export interface ClipboardPasteSource {
72
+ pasteImageFromClipboard: typeof pasteImageFromClipboard;
73
+ pasteFromClipboard: typeof pasteFromClipboard;
74
+ }
75
+
59
76
  export function registerPaste(
60
77
  state: PasteRegistryState,
61
78
  content: string,
@@ -436,22 +453,34 @@ export function handleClipboardPaste(
436
453
  requestRender: () => void;
437
454
  },
438
455
  projectRoot: string,
439
- ): { prompt: string; cursorPos: number; nextImageId: number; nextPasteId: number } {
440
- state.saveUndoState();
441
- const img = pasteImageFromClipboard();
456
+ clipboard: ClipboardPasteSource = { pasteImageFromClipboard, pasteFromClipboard },
457
+ ): ClipboardPasteResult {
458
+ const img = clipboard.pasteImageFromClipboard();
459
+ let pasted = false;
460
+ let kind: ClipboardPasteKind = 'none';
461
+ let insertedMarker: string | undefined;
462
+
442
463
  if (img) {
464
+ state.saveUndoState();
443
465
  const id = `img${state.nextImageId++}`;
444
466
  const sizeKB = Math.round(img.data.length * 3 / 4 / 1024);
445
467
  state.imageRegistry.set(id, img);
446
468
  const marker = `[IMAGE: ${id}, clipboard, ${sizeKB}KB]`;
447
469
  state.prompt = state.prompt.slice(0, state.cursorPos) + marker + state.prompt.slice(state.cursorPos);
448
470
  state.cursorPos += marker.length;
471
+ pasted = true;
472
+ kind = 'image';
473
+ insertedMarker = marker;
449
474
  } else {
450
- const raw = pasteFromClipboard();
475
+ const raw = clipboard.pasteFromClipboard();
451
476
  if (raw) {
477
+ state.saveUndoState();
452
478
  const { marker } = registerPaste(state, raw, projectRoot);
453
479
  state.prompt = state.prompt.slice(0, state.cursorPos) + marker + state.prompt.slice(state.cursorPos);
454
480
  state.cursorPos += marker.length;
481
+ pasted = true;
482
+ kind = marker.startsWith('[IMAGE:') ? 'image' : 'text';
483
+ insertedMarker = marker;
455
484
  }
456
485
  }
457
486
  state.ensureInputCursorVisible();
@@ -461,5 +490,8 @@ export function handleClipboardPaste(
461
490
  cursorPos: state.cursorPos,
462
491
  nextImageId: state.nextImageId,
463
492
  nextPasteId: state.nextPasteId,
493
+ pasted,
494
+ kind,
495
+ marker: insertedMarker,
464
496
  };
465
497
  }
@@ -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;
@@ -99,6 +100,7 @@ export interface InputFeedContext {
99
100
  contentWidth: number;
100
101
  readonly pasteRegistry: Map<string, string>;
101
102
  readonly imageRegistry: Map<string, { data: string; mediaType: string }>;
103
+ readonly projectRoot: string;
102
104
  readonly selection: SelectionManager;
103
105
  readonly selectionModal: SelectionModal;
104
106
  selectionCallback: ((result: SelectionResult | null) => void) | null;
@@ -353,20 +355,31 @@ export function feedInputTokens(context: InputFeedContext, tokens: readonly Inpu
353
355
  conversationManager: context.conversationManager,
354
356
  requestRender: context.requestRender,
355
357
  handleEscape: context.handleEscape,
358
+ projectRoot: context.projectRoot,
359
+ pasteRegistry: context.pasteRegistry,
360
+ imageRegistry: context.imageRegistry,
361
+ nextPasteId: context.nextPasteId,
362
+ nextImageId: context.nextImageId,
363
+ saveUndoState: context.saveUndoState,
364
+ ensureInputCursorVisible: () => context.ensureInputCursorVisible(),
356
365
  };
357
366
  if (handleCommandModeToken(commandState, token)) {
358
367
  context.commandMode = commandState.commandMode;
359
368
  context.prompt = commandState.prompt;
360
369
  context.cursorPos = commandState.cursorPos;
361
370
  context.panelFocused = commandState.panelFocused;
371
+ context.nextPasteId = commandState.nextPasteId;
372
+ context.nextImageId = commandState.nextImageId;
362
373
  continue;
363
374
  }
364
375
 
365
376
  const keyRoute = handlePromptKeyToken({
366
377
  prompt: context.prompt,
367
378
  cursorPos: context.cursorPos,
379
+ inputScrollTop: context.inputScrollTop,
368
380
  commandMode: context.commandMode,
369
381
  contentWidth: context.contentWidth,
382
+ maxInputRows: 8,
370
383
  inputHistory: context.inputHistory,
371
384
  indicatorFocused: context.indicatorFocused,
372
385
  conversationManager: context.conversationManager,
@@ -391,6 +404,7 @@ export function feedInputTokens(context: InputFeedContext, tokens: readonly Inpu
391
404
  if (keyRoute.handled) {
392
405
  context.prompt = keyRoute.prompt;
393
406
  context.cursorPos = keyRoute.cursorPos;
407
+ context.inputScrollTop = keyRoute.inputScrollTop;
394
408
  context.commandMode = keyRoute.commandMode;
395
409
  context.indicatorFocused = keyRoute.indicatorFocused;
396
410
  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,
@@ -271,6 +271,7 @@ export class InputHandler {
271
271
  selection: this.selection,
272
272
  pasteRegistry: this.pasteRegistry,
273
273
  imageRegistry: this.imageRegistry,
274
+ projectRoot: this.uiServices.environment.shellPaths.workingDirectory,
274
275
  selectionModal: this.selectionModal,
275
276
  bookmarkModal: this.bookmarkModal,
276
277
  settingsModal: this.settingsModal,
@@ -338,7 +339,7 @@ export class InputHandler {
338
339
  /** Sync mutable handler fields back into feedContext after in-feed mutations. */
339
340
  public syncFeedContextMutableFields(): void {
340
341
  const h = this;
341
- syncFeedContextMutableFields({ prompt: h.prompt, cursorPos: h.cursorPos, commandMode: h.commandMode,
342
+ syncFeedContextMutableFields({ prompt: h.prompt, cursorPos: h.cursorPos, inputScrollTop: h.inputScrollTop, commandMode: h.commandMode,
342
343
  panelFocused: h.panelFocused, indicatorFocused: h.indicatorFocused, helpOverlayActive: h.helpOverlayActive,
343
344
  helpScrollOffset: h.helpScrollOffset, shortcutsOverlayActive: h.shortcutsOverlayActive,
344
345
  shortcutsScrollOffset: h.shortcutsScrollOffset, selectionCallback: h.selectionCallback,
@@ -445,6 +446,7 @@ export class InputHandler {
445
446
  // Sync mutable scalars from handler into the reused context.
446
447
  context.prompt = this.prompt;
447
448
  context.cursorPos = this.cursorPos;
449
+ context.inputScrollTop = this.inputScrollTop;
448
450
  context.commandMode = this.commandMode;
449
451
  context.panelFocused = this.panelFocused;
450
452
  context.indicatorFocused = this.indicatorFocused;
@@ -473,6 +475,7 @@ export class InputHandler {
473
475
  feedInputTokens(context, this.tokenizer.feed(data));
474
476
  this.prompt = context.prompt;
475
477
  this.cursorPos = context.cursorPos;
478
+ this.inputScrollTop = context.inputScrollTop;
476
479
  this.commandMode = context.commandMode;
477
480
  this.panelFocused = context.panelFocused;
478
481
  this.indicatorFocused = context.indicatorFocused;
@@ -500,7 +503,7 @@ export class InputHandler {
500
503
  * handlePaste - Shared paste logic for Ctrl+V and middle-click.
501
504
  * Tries image clipboard first, falls back to text paste.
502
505
  */
503
- public handlePaste(): void {
506
+ public handlePaste(): ReturnType<typeof handleClipboardPaste> {
504
507
  const result = handleClipboardPaste({
505
508
  prompt: this.prompt,
506
509
  cursorPos: this.cursorPos,
@@ -516,6 +519,11 @@ export class InputHandler {
516
519
  this.cursorPos = result.cursorPos;
517
520
  this.nextImageId = result.nextImageId;
518
521
  this.nextPasteId = result.nextPasteId;
522
+ if (!result.pasted) {
523
+ this.conversationManager?.log('[Paste: clipboard does not contain supported text or image data]', { fg: '240' });
524
+ this.requestRender();
525
+ }
526
+ return result;
519
527
  }
520
528
 
521
529
  /** Content width for wrapping — set by main.ts via setContentWidth(). */
@@ -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
@@ -376,6 +376,7 @@ async function main() {
376
376
  commandContext.submitInput = submitInput;
377
377
  commandContext.submitSpokenInput = (text, content) => submitInput(text, content, { spokenOutput: true });
378
378
  commandContext.stopSpokenOutput = () => spokenTurns.stop();
379
+ commandContext.pasteFromClipboard = () => input.handlePaste();
379
380
  commandContext.executeCommand = (name, args) => commandRegistry.execute(name, args, commandContext);
380
381
  commandContext.cancelGeneration = cancelGeneration;
381
382
  commandContext.jumpToBookmark = jumpToBookmark;
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.95';
9
+ let _version = '0.19.98';
10
10
  try {
11
11
  const pkg = JSON.parse(readFileSync(join(import.meta.dir, '..', 'package.json'), 'utf-8'));
12
12
  _version = pkg.version ?? _version;