codeep 2.14.0 → 2.16.0

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.
Files changed (70) hide show
  1. package/README.md +47 -27
  2. package/dist/acp/commands.js +22 -1
  3. package/dist/acp/server.js +13 -2
  4. package/dist/acp/session.js +22 -1
  5. package/dist/config/index.d.ts +10 -0
  6. package/dist/config/index.js +2 -2
  7. package/dist/config/providers.js +35 -24
  8. package/dist/renderer/App.d.ts +77 -30
  9. package/dist/renderer/App.js +429 -659
  10. package/dist/renderer/agentExecution.d.ts +1 -0
  11. package/dist/renderer/agentExecution.js +3 -2
  12. package/dist/renderer/commands/helpers.d.ts +251 -0
  13. package/dist/renderer/commands/helpers.js +450 -0
  14. package/dist/renderer/commands/registry.js +7 -1
  15. package/dist/renderer/commands.d.ts +4 -0
  16. package/dist/renderer/commands.js +363 -318
  17. package/dist/renderer/components/ActionFormatting.d.ts +17 -0
  18. package/dist/renderer/components/ActionFormatting.js +67 -0
  19. package/dist/renderer/components/Autocomplete.d.ts +58 -0
  20. package/dist/renderer/components/Autocomplete.js +75 -0
  21. package/dist/renderer/components/Intro.d.ts +9 -0
  22. package/dist/renderer/components/Intro.js +5 -15
  23. package/dist/renderer/components/MessageFormatter.d.ts +96 -0
  24. package/dist/renderer/components/MessageFormatter.js +375 -0
  25. package/dist/renderer/components/Permission.d.ts +4 -0
  26. package/dist/renderer/components/Permission.js +1 -1
  27. package/dist/renderer/components/Status.d.ts +4 -0
  28. package/dist/renderer/components/Status.js +2 -3
  29. package/dist/renderer/components/WelcomeFormatter.d.ts +19 -0
  30. package/dist/renderer/components/WelcomeFormatter.js +79 -0
  31. package/dist/renderer/components/uiConstants.d.ts +8 -0
  32. package/dist/renderer/components/uiConstants.js +24 -0
  33. package/dist/renderer/inputParsing.d.ts +22 -0
  34. package/dist/renderer/inputParsing.js +28 -0
  35. package/dist/renderer/layout.d.ts +219 -0
  36. package/dist/renderer/layout.js +338 -0
  37. package/dist/renderer/main.d.ts +2 -1
  38. package/dist/renderer/main.js +79 -11
  39. package/dist/renderer/ollamaHint.d.ts +12 -0
  40. package/dist/renderer/ollamaHint.js +29 -0
  41. package/dist/utils/agentChat.js +23 -1
  42. package/dist/utils/codeepCloud.d.ts +54 -0
  43. package/dist/utils/codeepCloud.js +95 -0
  44. package/dist/utils/diffPreview.d.ts +31 -0
  45. package/dist/utils/diffPreview.js +102 -0
  46. package/dist/utils/export.d.ts +12 -0
  47. package/dist/utils/export.js +3 -3
  48. package/dist/utils/git.d.ts +28 -0
  49. package/dist/utils/git.js +111 -1
  50. package/dist/utils/hooks.d.ts +26 -0
  51. package/dist/utils/hooks.js +69 -1
  52. package/dist/utils/keychain.js +45 -29
  53. package/dist/utils/logger.d.ts +12 -0
  54. package/dist/utils/logger.js +1 -1
  55. package/dist/utils/mcpConfig.d.ts +26 -0
  56. package/dist/utils/mcpConfig.js +109 -4
  57. package/dist/utils/mentions.d.ts +195 -0
  58. package/dist/utils/mentions.js +672 -0
  59. package/dist/utils/skillBundles.d.ts +14 -0
  60. package/dist/utils/skillBundles.js +3 -3
  61. package/dist/utils/skillBundlesCloud.d.ts +7 -0
  62. package/dist/utils/skillBundlesCloud.js +1 -1
  63. package/dist/utils/tokenTracker.js +21 -5
  64. package/dist/utils/toolParsing.d.ts +11 -0
  65. package/dist/utils/toolParsing.js +6 -0
  66. package/dist/utils/webFetch.d.ts +101 -0
  67. package/dist/utils/webFetch.js +375 -0
  68. package/dist/version.d.ts +1 -1
  69. package/dist/version.js +1 -1
  70. package/package.json +2 -2
@@ -4,25 +4,18 @@
4
4
  */
5
5
  import { Screen } from './Screen.js';
6
6
  import { Input, LineEditor } from './Input.js';
7
- import { fg, style, stringWidth } from './ansi.js';
8
- import { SYNTAX, highlightCode } from './highlight.js';
7
+ import { fg, style } from './ansi.js';
8
+ import { PRIMARY_COLOR, SPINNER_FRAMES, LOGO_LINES } from './components/uiConstants.js';
9
+ import { bottomPanelHeight, chatLayout, messageOffsets, scrollOffsetForTarget, scrollWindow, formatTokenCount, statusBarRightHint, activePanel, computeInputDisplay, agentProgressBar, truncateNotification, shouldShowPasteDialog, buildPasteInfo } from './layout.js';
10
+ import { parseCommandInput } from './inputParsing.js';
11
+ import { formatWelcomeMessage } from './components/WelcomeFormatter.js';
12
+ import { filterCommands, detectMentionQuery } from './components/Autocomplete.js';
13
+ import { suggestMentions } from '../utils/mentions.js';
9
14
  import { handleInlineStatusKey, handleInlineHelpKey, handleMenuKey, handleInlinePermissionKey, handleInlineSessionPickerKey, handleInlineConfirmKey, handleLoginKey, } from './handlers.js';
10
15
  import clipboardy from 'clipboardy';
11
16
  import { readImageFromClipboard } from '../utils/clipboard.js';
12
- // Primary color: #f02a30 (Codeep red)
13
- const PRIMARY_COLOR = fg.rgb(240, 42, 48);
14
- // 8-bit block spinner frames
15
- const SPINNER_FRAMES = ['▖', '▘', '▝', '▗', '▌', '▀', '▐', '▄'];
16
- // ASCII Logo
17
- const LOGO_LINES = [
18
- ' ██████╗ ██████╗ ██████╗ ███████╗███████╗██████╗ ',
19
- '██╔════╝██╔═══██╗██╔══██╗██╔════╝██╔════╝██╔══██╗',
20
- '██║ ██║ ██║██║ ██║█████╗ █████╗ ██████╔╝',
21
- '██║ ██║ ██║██║ ██║██╔══╝ ██╔══╝ ██╔═══╝ ',
22
- '╚██████╗╚██████╔╝██████╔╝███████╗███████╗██║ ',
23
- ' ╚═════╝ ╚═════╝ ╚═════╝ ╚══════╝╚══════╝╚═╝ ',
24
- ];
25
- const LOGO_HEIGHT = LOGO_LINES.length;
17
+ // (PRIMARY_COLOR, SPINNER_FRAMES, LOGO_LINES, LOGO_HEIGHT moved to
18
+ // ./components/uiConstants — imported above.)
26
19
  // ─── Command metadata ────────────────────────────────────────────────────────
27
20
  //
28
21
  // `COMMAND_DESCRIPTIONS` used to be hand-maintained here in App.ts and kept in
@@ -34,6 +27,7 @@ import { handleSettingsKey, SETTINGS } from './components/Settings.js';
34
27
  import { renderExportPanel, handleExportKey as handleExportKeyComponent } from './components/Export.js';
35
28
  import { renderLogoutPanel, handleLogoutKey as handleLogoutKeyComponent } from './components/Logout.js';
36
29
  import { renderSearchPanel, handleSearchKey as handleSearchKeyComponent } from './components/Search.js';
30
+ import { formatMessage as formatMessageFn, } from './components/MessageFormatter.js';
37
31
  export class App {
38
32
  screen;
39
33
  input;
@@ -66,7 +60,7 @@ export class App {
66
60
  // Paste detection state
67
61
  pasteInfo = null;
68
62
  pasteInfoOpen = false;
69
- codeBlockCounter = 0; // Global code block counter for /copy numbering
63
+ codeBlockCounter = { current: 0 }; // Global code block counter for /copy numbering
70
64
  // Message render cache: index → { lines, width, startBlock, blockCount }
71
65
  messageCache = [];
72
66
  // Inline help state
@@ -84,10 +78,26 @@ export class App {
84
78
  showAutocomplete = false;
85
79
  autocompleteIndex = 0;
86
80
  autocompleteItems = [];
81
+ // `@mention` autocomplete state — separate from the `/command` picker
82
+ // because mentions appear mid-sentence (not just at the start) and
83
+ // insert a file path (not a slash command). `mentionAtStart` is the
84
+ // index of the `@` in the editor value, used to replace `@query` with
85
+ // `@selectedPath` on Tab/Enter.
86
+ showMentionAutocomplete = false;
87
+ mentionIndex = 0;
88
+ mentionItems = [];
89
+ mentionAtStart = 0;
90
+ /** Project root for resolving `suggestMentions`. Cached per update. */
91
+ mentionRoot = '';
87
92
  // Inline confirmation dialog state
88
93
  confirmOpen = false;
89
94
  confirmOptions = null;
90
95
  confirmSelection = 'no';
96
+ // Inline hunk-picker state (`/apply --interactive`)
97
+ hunkPickerOpen = false;
98
+ hunkPickerOptions = null;
99
+ hunkPickerIndex = 0;
100
+ hunkPickerAccepted = [];
91
101
  // Inline menu state (renders below input/status)
92
102
  menuOpen = false;
93
103
  menuTitle = '';
@@ -228,26 +238,10 @@ export class App {
228
238
  scrollToMessage(messageIndex) {
229
239
  const { width, height } = this.screen.getSize();
230
240
  const maxWidth = width - 4; // Account for margins
231
- // Calculate actual line count for messages up to target
232
- let totalLines = 0;
233
- let targetStartLine = 0;
234
- for (let i = 0; i < this.messages.length; i++) {
235
- const msg = this.messages[i];
236
- if (i === messageIndex) {
237
- targetStartLine = totalLines;
238
- }
239
- // Count lines for this message (header + content)
240
- const contentLines = msg.content.split('\n');
241
- let msgLines = 2; // Header + empty line after
242
- for (const line of contentLines) {
243
- // Account for word wrapping
244
- msgLines += Math.ceil(Math.max(1, line.length) / maxWidth);
245
- }
246
- totalLines += msgLines + 1; // +1 for spacing between messages
247
- }
241
+ const { totalLines, targetStartLine } = messageOffsets(this.messages.map((m) => m.content), maxWidth, messageIndex);
248
242
  const visibleLines = height - 12; // Approximate visible area
249
243
  // Set scroll offset to show the target message near the top
250
- this.scrollOffset = Math.max(0, totalLines - targetStartLine - Math.floor(visibleLines / 2));
244
+ this.scrollOffset = scrollOffsetForTarget(totalLines, targetStartLine, visibleLines);
251
245
  this.scheduleRender();
252
246
  this.notify(`Jumped to message #${messageIndex + 1}`);
253
247
  }
@@ -409,10 +403,8 @@ export class App {
409
403
  * Handle paste detection - call this when large text is pasted
410
404
  */
411
405
  handlePaste(text) {
412
- const lines = text.split('\n');
413
- const chars = text.length;
414
406
  // Only show paste info for significant pastes (>100 chars or >3 lines)
415
- if (chars < 100 && lines.length <= 3) {
407
+ if (!shouldShowPasteDialog(text)) {
416
408
  // Small paste - just add to input directly
417
409
  this.editor.insert(text);
418
410
  this.updateAutocomplete();
@@ -420,13 +412,7 @@ export class App {
420
412
  return;
421
413
  }
422
414
  // Large paste - show info box
423
- const preview = text.length > 200 ? text.slice(0, 197) + '...' : text;
424
- this.pasteInfo = {
425
- chars,
426
- lines: lines.length,
427
- preview,
428
- fullText: text,
429
- };
415
+ this.pasteInfo = buildPasteInfo(text);
430
416
  this.pasteInfoOpen = true;
431
417
  this.scheduleRender();
432
418
  }
@@ -538,6 +524,17 @@ export class App {
538
524
  this.confirmOpen = true;
539
525
  this.scheduleRender();
540
526
  }
527
+ /**
528
+ * Show the interactive hunk picker (`/apply --interactive`).
529
+ * The caller passes pre-built items + an `onComplete` callback.
530
+ */
531
+ showHunkPicker(options) {
532
+ this.hunkPickerOptions = options;
533
+ this.hunkPickerIndex = 0;
534
+ this.hunkPickerAccepted = [];
535
+ this.hunkPickerOpen = true;
536
+ this.scheduleRender();
537
+ }
541
538
  /**
542
539
  * Show permission dialog (inline, below status bar)
543
540
  */
@@ -720,73 +717,75 @@ export class App {
720
717
  * Handle chat screen keys
721
718
  */
722
719
  handleChatKey(event) {
723
- // If paste info is open, handle paste keys first
724
- if (this.pasteInfoOpen) {
725
- this.handlePasteInfoKey(event);
726
- return;
727
- }
728
- // If permission is open, handle permission keys first
729
- if (this.permissionOpen) {
730
- this.handleInlinePermissionKey(event);
731
- return;
732
- }
733
- // If session picker is open, handle session picker keys first
734
- if (this.sessionPickerOpen) {
735
- this.handleInlineSessionPickerKey(event);
736
- return;
737
- }
738
- // If confirm is open, handle confirm keys first
739
- if (this.confirmOpen) {
740
- this.handleInlineConfirmKey(event);
741
- return;
742
- }
743
- // If status is open, handle status keys first
744
- if (this.statusOpen) {
745
- this.handleInlineStatusKey(event);
746
- return;
747
- }
748
- // If help is open, handle help keys first
749
- if (this.helpOpen) {
750
- this.handleInlineHelpKey(event);
751
- return;
752
- }
753
- // If settings is open, handle settings keys first
754
- if (this.settingsOpen) {
755
- this.handleInlineSettingsKey(event);
756
- return;
757
- }
758
- // If search is open, handle search keys first
759
- if (this.searchOpen) {
760
- this.handleSearchKey(event);
761
- return;
762
- }
763
- // If export is open, handle export keys first
764
- if (this.exportOpen) {
765
- this.handleExportKey(event);
766
- return;
767
- }
768
- // If logout is open, handle logout keys first
769
- if (this.logoutOpen) {
770
- this.handleLogoutKey(event);
771
- return;
772
- }
773
- // If login is open, handle login keys first
774
- if (this.loginOpen) {
775
- this.handleLoginKey(event);
776
- return;
720
+ // Dispatch to whichever inline panel currently owns focus.
721
+ switch (activePanel({
722
+ pasteInfoOpen: this.pasteInfoOpen,
723
+ permissionOpen: this.permissionOpen,
724
+ sessionPickerOpen: this.sessionPickerOpen,
725
+ confirmOpen: this.confirmOpen,
726
+ statusOpen: this.statusOpen,
727
+ helpOpen: this.helpOpen,
728
+ settingsOpen: this.settingsOpen,
729
+ searchOpen: this.searchOpen,
730
+ exportOpen: this.exportOpen,
731
+ logoutOpen: this.logoutOpen,
732
+ loginOpen: this.loginOpen,
733
+ menuOpen: this.menuOpen,
734
+ showAutocomplete: this.showAutocomplete,
735
+ hunkPickerOpen: this.hunkPickerOpen,
736
+ })) {
737
+ case 'pasteInfo':
738
+ this.handlePasteInfoKey(event);
739
+ return;
740
+ case 'permission':
741
+ this.handleInlinePermissionKey(event);
742
+ return;
743
+ case 'sessionPicker':
744
+ this.handleInlineSessionPickerKey(event);
745
+ return;
746
+ case 'confirm':
747
+ this.handleInlineConfirmKey(event);
748
+ return;
749
+ case 'status':
750
+ this.handleInlineStatusKey(event);
751
+ return;
752
+ case 'help':
753
+ this.handleInlineHelpKey(event);
754
+ return;
755
+ case 'settings':
756
+ this.handleInlineSettingsKey(event);
757
+ return;
758
+ case 'search':
759
+ this.handleSearchKey(event);
760
+ return;
761
+ case 'export':
762
+ this.handleExportKey(event);
763
+ return;
764
+ case 'logout':
765
+ this.handleLogoutKey(event);
766
+ return;
767
+ case 'login':
768
+ this.handleLoginKey(event);
769
+ return;
770
+ case 'menu':
771
+ this.handleMenuKey(event);
772
+ return;
773
+ case 'hunkPicker':
774
+ this.handleHunkPickerKey(event);
775
+ return;
777
776
  }
778
- // If intro is playing, skip on any key
777
+ // If intro is playing, skip on any key.
779
778
  if (this.showIntro) {
780
779
  this.skipIntro();
781
780
  return;
782
781
  }
783
- // If menu is open, handle menu keys first
784
- if (this.menuOpen) {
785
- this.handleMenuKey(event);
786
- return;
787
- }
788
782
  // Escape to cancel streaming/loading/agent or close autocomplete
789
783
  if (event.key === 'escape') {
784
+ if (this.showMentionAutocomplete) {
785
+ this.showMentionAutocomplete = false;
786
+ this.scheduleRender();
787
+ return;
788
+ }
790
789
  if (this.showAutocomplete) {
791
790
  this.showAutocomplete = false;
792
791
  this.scheduleRender();
@@ -808,7 +807,7 @@ export class App {
808
807
  }
809
808
  return;
810
809
  }
811
- // Handle autocomplete navigation
810
+ // Handle autocomplete navigation (`/command` picker)
812
811
  if (this.showAutocomplete) {
813
812
  if (event.key === 'up') {
814
813
  this.autocompleteIndex = Math.max(0, this.autocompleteIndex - 1);
@@ -831,6 +830,28 @@ export class App {
831
830
  }
832
831
  }
833
832
  }
833
+ // Handle `@mention` autocomplete navigation
834
+ if (this.showMentionAutocomplete) {
835
+ if (event.key === 'up') {
836
+ this.mentionIndex = Math.max(0, this.mentionIndex - 1);
837
+ this.scheduleRender();
838
+ return;
839
+ }
840
+ if (event.key === 'down') {
841
+ this.mentionIndex = Math.min(this.mentionItems.length - 1, this.mentionIndex + 1);
842
+ this.scheduleRender();
843
+ return;
844
+ }
845
+ if (event.key === 'tab') {
846
+ // Replace `@query` with `@selectedPath` in the editor.
847
+ if (this.mentionItems.length > 0) {
848
+ this.applyMentionSelection();
849
+ this.showMentionAutocomplete = false;
850
+ this.scheduleRender();
851
+ return;
852
+ }
853
+ }
854
+ }
834
855
  // Ctrl+L to clear
835
856
  if (event.ctrl && event.key === 'l') {
836
857
  this.clearMessages();
@@ -968,17 +989,60 @@ export class App {
968
989
  */
969
990
  updateAutocomplete() {
970
991
  const value = this.editor.getValue();
971
- // Show autocomplete only when typing a command
972
- if (value.startsWith('/') && !value.includes(' ')) {
973
- const query = value.slice(1).toLowerCase();
974
- this.autocompleteItems = App.COMMANDS.filter(cmd => cmd.startsWith(query)).slice(0, 8); // Max 8 items
975
- this.showAutocomplete = this.autocompleteItems.length > 0 && query.length > 0;
976
- this.autocompleteIndex = 0;
977
- }
978
- else {
992
+ const cursorPos = this.editor.getCursorPos();
993
+ // `/command` picker — only when the input starts with `/` and the
994
+ // cursor is in the command-name segment (no space yet).
995
+ const result = filterCommands(value, App.COMMANDS);
996
+ if (result === null) {
979
997
  this.showAutocomplete = false;
980
998
  this.autocompleteItems = [];
981
999
  }
1000
+ else {
1001
+ this.autocompleteItems = result.items;
1002
+ this.showAutocomplete = result.items.length > 0;
1003
+ this.autocompleteIndex = result.index;
1004
+ }
1005
+ // `@mention` picker — detect an in-progress mention at the cursor.
1006
+ // Independent of the `/` picker so the two never compete.
1007
+ const mention = detectMentionQuery(value, cursorPos);
1008
+ if (mention) {
1009
+ const root = this.options.getProjectRoot?.() ?? process.cwd();
1010
+ this.mentionRoot = root;
1011
+ this.mentionAtStart = mention.atStart;
1012
+ this.mentionItems = suggestMentions({ root, query: mention.query, limit: 10 });
1013
+ this.showMentionAutocomplete = this.mentionItems.length > 0;
1014
+ this.mentionIndex = 0;
1015
+ }
1016
+ else {
1017
+ this.showMentionAutocomplete = false;
1018
+ this.mentionItems = [];
1019
+ }
1020
+ }
1021
+ /**
1022
+ * Replace the in-progress `@query` (from `mentionAtStart` to the
1023
+ * cursor) with the selected mention's path. Keeps the `@` prefix and
1024
+ * positions the cursor right after the inserted path so the user can
1025
+ * keep typing the rest of the message.
1026
+ */
1027
+ applyMentionSelection() {
1028
+ if (this.mentionItems.length === 0)
1029
+ return;
1030
+ const selected = this.mentionItems[this.mentionIndex];
1031
+ const value = this.editor.getValue();
1032
+ const cursor = this.editor.getCursorPos();
1033
+ if (this.mentionAtStart >= value.length)
1034
+ return;
1035
+ // `mentionAtStart` is the index OF the `@`, so this slice EXCLUDES it —
1036
+ // re-add the sigil or the completed path is no longer a mention and the
1037
+ // file never gets attached.
1038
+ const before = value.slice(0, this.mentionAtStart) + '@';
1039
+ const after = value.slice(cursor);
1040
+ const next = before + selected.insertPath + ' ' + after;
1041
+ this.editor.setValue(next);
1042
+ const newCursor = (before + selected.insertPath + ' ').length;
1043
+ this.editor.setCursorPos(newCursor);
1044
+ // The picker may still have matches for the new prefix — refresh.
1045
+ this.updateAutocomplete();
982
1046
  }
983
1047
  /**
984
1048
  * Handle inline status keys
@@ -1202,6 +1266,85 @@ export class App {
1202
1266
  render: () => this.scheduleRender(),
1203
1267
  });
1204
1268
  }
1269
+ /**
1270
+ * Handle keys in the interactive hunk picker.
1271
+ * y / Enter / → accept this hunk, advance
1272
+ * n / ← skip this hunk, advance
1273
+ * a accept this + all remaining, finish
1274
+ * q / Esc finish without accepting this hunk
1275
+ * ↑ / ↓ navigate (preview only — no decision)
1276
+ */
1277
+ handleHunkPickerKey(event) {
1278
+ const opts = this.hunkPickerOptions;
1279
+ if (!opts) {
1280
+ this.hunkPickerOpen = false;
1281
+ this.scheduleRender();
1282
+ return;
1283
+ }
1284
+ const finish = () => {
1285
+ const accepted = this.hunkPickerAccepted;
1286
+ const cb = opts.onComplete;
1287
+ this.hunkPickerOptions = null;
1288
+ this.hunkPickerOpen = false;
1289
+ this.hunkPickerAccepted = [];
1290
+ this.hunkPickerIndex = 0;
1291
+ cb(accepted);
1292
+ this.scheduleRender();
1293
+ };
1294
+ const advance = () => {
1295
+ if (this.hunkPickerIndex >= opts.items.length - 1) {
1296
+ finish();
1297
+ }
1298
+ else {
1299
+ this.hunkPickerIndex++;
1300
+ this.scheduleRender();
1301
+ }
1302
+ };
1303
+ const acceptCurrent = () => {
1304
+ const item = opts.items[this.hunkPickerIndex];
1305
+ if (item) {
1306
+ this.hunkPickerAccepted.push({ path: item.path, hunkIndex: item.hunkIndex });
1307
+ }
1308
+ advance();
1309
+ };
1310
+ switch (event.key) {
1311
+ case 'y':
1312
+ case 'enter':
1313
+ case 'right':
1314
+ acceptCurrent();
1315
+ return;
1316
+ case 'n':
1317
+ case 'left':
1318
+ advance();
1319
+ return;
1320
+ case 'a':
1321
+ // Accept current + all remaining.
1322
+ for (let i = this.hunkPickerIndex; i < opts.items.length; i++) {
1323
+ const item = opts.items[i];
1324
+ this.hunkPickerAccepted.push({ path: item.path, hunkIndex: item.hunkIndex });
1325
+ }
1326
+ finish();
1327
+ return;
1328
+ case 'q':
1329
+ case 'escape':
1330
+ finish();
1331
+ return;
1332
+ case 'up':
1333
+ if (this.hunkPickerIndex > 0) {
1334
+ this.hunkPickerIndex--;
1335
+ this.scheduleRender();
1336
+ }
1337
+ return;
1338
+ case 'down':
1339
+ if (this.hunkPickerIndex < opts.items.length - 1) {
1340
+ this.hunkPickerIndex++;
1341
+ this.scheduleRender();
1342
+ }
1343
+ return;
1344
+ default:
1345
+ return;
1346
+ }
1347
+ }
1205
1348
  /**
1206
1349
  * Submit the current input buffer (used by Enter and Escape-in-multiline)
1207
1350
  */
@@ -1228,9 +1371,10 @@ export class App {
1228
1371
  * Handle command
1229
1372
  */
1230
1373
  handleCommand(input) {
1231
- const parts = input.slice(1).split(' ');
1232
- const command = parts[0].toLowerCase();
1233
- const args = parts.slice(1);
1374
+ const parsed = parseCommandInput(input);
1375
+ if (!parsed)
1376
+ return;
1377
+ const { command, args } = parsed;
1234
1378
  switch (command) {
1235
1379
  case 'help':
1236
1380
  this.helpOpen = true;
@@ -1297,59 +1441,43 @@ export class App {
1297
1441
  }
1298
1442
  this.screen.clear();
1299
1443
  // If menu or settings is open, reserve space for it at bottom
1300
- let bottomPanelHeight = 0;
1301
- if (this.pasteInfoOpen && this.pasteInfo) {
1302
- const previewLines = Math.min(this.pasteInfo.preview.split('\n').length, 5);
1303
- bottomPanelHeight = previewLines + 6; // title + preview + extra line indicator + options
1304
- }
1305
- else if (this.isAgentRunning && !(this.confirmOpen && this.confirmOptions)) {
1306
- bottomPanelHeight = 9; // Agent progress box: top + 5 log lines + stats + bottom + 1 margin
1307
- }
1308
- else if (this.permissionOpen) {
1309
- bottomPanelHeight = 10; // Permission dialog
1310
- }
1311
- else if (this.sessionPickerOpen) {
1312
- bottomPanelHeight = Math.min(this.sessionPickerItems.length + 6, 14); // Session picker
1313
- }
1314
- else if (this.confirmOpen && this.confirmOptions) {
1315
- bottomPanelHeight = this.confirmOptions.message.length + 5; // title + messages + buttons + padding
1316
- }
1317
- else if (this.statusOpen) {
1318
- bottomPanelHeight = 16; // Status info panel
1319
- }
1320
- else if (this.helpOpen) {
1321
- bottomPanelHeight = Math.min(height - 6, 20); // Help takes more space
1322
- }
1323
- else if (this.searchOpen) {
1324
- bottomPanelHeight = Math.min(this.searchResults.length * 3 + 6, 18); // Search results
1325
- }
1326
- else if (this.exportOpen) {
1327
- bottomPanelHeight = 10; // Export dialog
1328
- }
1329
- else if (this.logoutOpen) {
1330
- bottomPanelHeight = Math.min(this.logoutProviders.length + 6, 12); // Logout picker
1331
- }
1332
- else if (this.loginOpen) {
1333
- bottomPanelHeight = this.loginStep === 'provider'
1334
- ? Math.min(this.loginProviders.length + 5, 14)
1335
- : 8; // Login dialog
1336
- }
1337
- else if (this.menuOpen) {
1338
- bottomPanelHeight = Math.min(this.menuItems.length + 4, 14);
1339
- }
1340
- else if (this.settingsOpen) {
1341
- bottomPanelHeight = Math.min(SETTINGS.length + 4, 16);
1342
- }
1343
- else if (this.showAutocomplete && this.autocompleteItems.length > 0) {
1344
- bottomPanelHeight = Math.min(this.autocompleteItems.length + 3, 12);
1345
- }
1346
- const mainHeight = height - bottomPanelHeight;
1347
- // Layout - main UI takes top portion
1348
- const messagesStart = 0;
1349
- const messagesEnd = Math.max(0, mainHeight - 4);
1350
- const separatorLine = Math.max(0, mainHeight - 3);
1351
- const inputLine = Math.max(0, mainHeight - 2);
1352
- const statusLine = Math.max(0, mainHeight - 1);
1444
+ const panelHeight = bottomPanelHeight({
1445
+ height,
1446
+ pasteInfoOpen: this.pasteInfoOpen,
1447
+ pasteInfoPreviewLines: this.pasteInfo ? this.pasteInfo.preview.split('\n').length : 0,
1448
+ isAgentRunning: this.isAgentRunning,
1449
+ confirmOpen: this.confirmOpen && !!this.confirmOptions,
1450
+ hunkPickerOpen: this.hunkPickerOpen && !!this.hunkPickerOptions,
1451
+ permissionOpen: this.permissionOpen,
1452
+ sessionPickerOpen: this.sessionPickerOpen,
1453
+ sessionPickerItemCount: this.sessionPickerItems.length,
1454
+ confirmMessageCount: this.confirmOptions?.message.length ?? 0,
1455
+ statusOpen: this.statusOpen,
1456
+ helpOpen: this.helpOpen,
1457
+ searchOpen: this.searchOpen,
1458
+ searchResultCount: this.searchResults.length,
1459
+ exportOpen: this.exportOpen,
1460
+ logoutOpen: this.logoutOpen,
1461
+ logoutProviderCount: this.logoutProviders.length,
1462
+ loginOpen: this.loginOpen,
1463
+ loginStep: this.loginStep,
1464
+ loginProviderCount: this.loginProviders.length,
1465
+ menuOpen: this.menuOpen,
1466
+ menuItemCount: this.menuItems.length,
1467
+ settingsOpen: this.settingsOpen,
1468
+ settingsCount: SETTINGS.length,
1469
+ showAutocomplete: this.showAutocomplete,
1470
+ autocompleteItemCount: this.autocompleteItems.length,
1471
+ mentionPickerOpen: this.showMentionAutocomplete,
1472
+ mentionItemCount: this.mentionItems.length,
1473
+ });
1474
+ const layout = chatLayout(height, panelHeight);
1475
+ const mainHeight = layout.mainHeight;
1476
+ const messagesStart = layout.messagesStart;
1477
+ const messagesEnd = layout.messagesEnd;
1478
+ const separatorLine = layout.separatorLine;
1479
+ const inputLine = layout.inputLine;
1480
+ const statusLine = layout.statusLine;
1353
1481
  // Messages
1354
1482
  const messagesHeight = Math.max(1, messagesEnd - messagesStart + 1);
1355
1483
  const messagesToRender = this.getVisibleMessages(messagesHeight, width - 2);
@@ -1408,10 +1536,17 @@ export class App {
1408
1536
  if (this.confirmOpen && this.confirmOptions) {
1409
1537
  this.renderInlineConfirm(statusLine + 1, width);
1410
1538
  }
1539
+ // Inline hunk picker renders BELOW status bar
1540
+ if (this.hunkPickerOpen && this.hunkPickerOptions) {
1541
+ this.renderInlineHunkPicker(statusLine + 1, width);
1542
+ }
1411
1543
  // Inline autocomplete renders BELOW status bar
1412
1544
  if (this.showAutocomplete && this.autocompleteItems.length > 0 && !this.menuOpen && !this.settingsOpen && !this.helpOpen && !this.confirmOpen && !this.permissionOpen && !this.sessionPickerOpen) {
1413
1545
  this.renderInlineAutocomplete(statusLine + 1, width);
1414
1546
  }
1547
+ else if (this.showMentionAutocomplete && this.mentionItems.length > 0 && !this.menuOpen && !this.settingsOpen && !this.helpOpen && !this.confirmOpen && !this.permissionOpen && !this.sessionPickerOpen) {
1548
+ this.renderInlineMentionPicker(statusLine + 1, width);
1549
+ }
1415
1550
  // Inline permission renders BELOW status bar
1416
1551
  if (this.permissionOpen) {
1417
1552
  this.renderInlinePermission(statusLine + 1, width);
@@ -1467,6 +1602,53 @@ export class App {
1467
1602
  // Footer
1468
1603
  this.screen.writeLine(y, '←/→ select • y/n quick • Enter confirm • Esc cancel', fg.gray);
1469
1604
  }
1605
+ /**
1606
+ * Render inline hunk picker (`/apply --interactive`).
1607
+ * Shows the current hunk's diff + the y/n/a/q key legend.
1608
+ */
1609
+ renderInlineHunkPicker(startY, width) {
1610
+ const opts = this.hunkPickerOptions;
1611
+ if (!opts)
1612
+ return;
1613
+ const item = opts.items[this.hunkPickerIndex];
1614
+ let y = startY;
1615
+ this.screen.horizontalLine(y++, '─', PRIMARY_COLOR);
1616
+ // Title + progress
1617
+ const progress = opts.items.length > 0
1618
+ ? ` (${this.hunkPickerIndex + 1}/${opts.items.length})`
1619
+ : '';
1620
+ this.screen.writeLine(y++, `${opts.title}${progress}`, PRIMARY_COLOR + style.bold);
1621
+ if (!item) {
1622
+ this.screen.writeLine(y++, 'No hunks to review.', fg.gray);
1623
+ this.screen.writeLine(y, 'Press any key to close.', fg.gray);
1624
+ return;
1625
+ }
1626
+ // File path + hunk header
1627
+ this.screen.writeLine(y++, `File: ${item.path}`, fg.cyan);
1628
+ this.screen.writeLine(y++, `Hunk: ${item.header}`, fg.gray);
1629
+ // Diff lines (capped to available vertical space; show up to 12)
1630
+ const maxDiffLines = 12;
1631
+ const lines = item.lines.slice(0, maxDiffLines);
1632
+ for (const line of lines) {
1633
+ const prefix = line.charAt(0);
1634
+ let color = fg.white;
1635
+ if (prefix === '+')
1636
+ color = fg.green;
1637
+ else if (prefix === '-')
1638
+ color = fg.red;
1639
+ else if (prefix === '@')
1640
+ color = fg.cyan;
1641
+ // Truncate long lines to terminal width.
1642
+ const truncated = line.length > width - 2 ? line.slice(0, width - 5) + '...' : line;
1643
+ this.screen.writeLine(y++, ` ${truncated}`, color);
1644
+ }
1645
+ if (item.lines.length > maxDiffLines) {
1646
+ this.screen.writeLine(y++, ` … (${item.lines.length - maxDiffLines} more lines)`, fg.gray);
1647
+ }
1648
+ y++;
1649
+ // Key legend
1650
+ this.screen.writeLine(y, 'y/Enter accept • n skip • a accept all • q/Esc quit • ↑/↓ navigate', fg.gray);
1651
+ }
1470
1652
  /**
1471
1653
  * Render input line
1472
1654
  */
@@ -1584,20 +1766,18 @@ export class App {
1584
1766
  return;
1585
1767
  }
1586
1768
  // Build prompt prefix
1587
- const lines = inputValue.split('\n');
1588
- const lineCount = lines.length;
1589
- // ❯ for normal, ❯❯ for multiline, [n] for multi-line with count
1590
- const promptSymbol = lineCount > 1 ? `[${lineCount}] ❯ ` : this.isMultilineMode ? '❯❯ ' : '❯ ';
1591
- const maxInputWidth = width - promptSymbol.length - 1;
1769
+ const display = computeInputDisplay({
1770
+ value: inputValue,
1771
+ cursorPos,
1772
+ width,
1773
+ isMultilineMode: this.isMultilineMode,
1774
+ });
1592
1775
  // Show placeholder when input is empty
1593
- if (!inputValue) {
1594
- this.screen.write(0, y, promptSymbol, PRIMARY_COLOR);
1595
- const placeholder = this.isMultilineMode
1596
- ? 'Multi-line mode Enter=newline · Esc=send'
1597
- : 'Message or /command';
1598
- this.screen.write(promptSymbol.length, y, placeholder, fg.gray);
1776
+ if (display.isEmpty) {
1777
+ this.screen.write(0, y, display.promptSymbol, PRIMARY_COLOR);
1778
+ this.screen.write(display.promptSymbol.length, y, display.placeholder, fg.gray);
1599
1779
  if (!hideCursor) {
1600
- this.screen.setCursor(promptSymbol.length, y);
1780
+ this.screen.setCursor(display.promptSymbol.length, y);
1601
1781
  this.screen.showCursor(true);
1602
1782
  }
1603
1783
  else {
@@ -1605,38 +1785,15 @@ export class App {
1605
1785
  }
1606
1786
  return;
1607
1787
  }
1608
- // For multi-line content, show the last line being edited
1609
- const lastLine = lines[lines.length - 1];
1610
- const displayInput = lineCount > 1 ? lastLine : inputValue;
1611
- const charsBeforeLastLine = lineCount > 1 ? inputValue.lastIndexOf('\n') + 1 : 0;
1612
- const cursorInLine = cursorPos - charsBeforeLastLine;
1613
- let displayValue;
1614
- let cursorX;
1615
- if (displayInput.length <= maxInputWidth) {
1616
- displayValue = displayInput;
1617
- cursorX = promptSymbol.length + Math.max(0, cursorInLine);
1618
- }
1619
- else {
1620
- const effectiveCursor = Math.max(0, cursorInLine);
1621
- const visibleStart = Math.max(0, effectiveCursor - Math.floor(maxInputWidth * 0.7));
1622
- const visibleEnd = visibleStart + maxInputWidth;
1623
- if (visibleStart > 0) {
1624
- displayValue = '…' + displayInput.slice(visibleStart + 1, visibleEnd);
1625
- }
1626
- else {
1627
- displayValue = displayInput.slice(0, maxInputWidth);
1628
- }
1629
- cursorX = promptSymbol.length + (effectiveCursor - visibleStart);
1630
- }
1631
1788
  // Prompt symbol in primary color, input text in white
1632
- this.screen.write(0, y, promptSymbol, PRIMARY_COLOR);
1633
- this.screen.write(promptSymbol.length, y, displayValue, fg.white);
1789
+ this.screen.write(0, y, display.promptSymbol, PRIMARY_COLOR);
1790
+ this.screen.write(display.promptSymbol.length, y, display.displayValue, fg.white);
1634
1791
  // Hide cursor when menu/settings is open
1635
1792
  if (hideCursor) {
1636
1793
  this.screen.showCursor(false);
1637
1794
  }
1638
1795
  else {
1639
- this.screen.setCursor(Math.min(cursorX, width - 1), y);
1796
+ this.screen.setCursor(Math.min(display.cursorX, width - 1), y);
1640
1797
  this.screen.showCursor(true);
1641
1798
  }
1642
1799
  }
@@ -1854,6 +2011,46 @@ export class App {
1854
2011
  const scrollInfo = items.length > maxVisible ? ` (${visibleStart + 1}-${visibleStart + visibleItems.length}/${items.length})` : '';
1855
2012
  this.screen.writeLine(y, `↑↓ navigate • Tab/Enter select • Esc cancel${scrollInfo}`, fg.gray);
1856
2013
  }
2014
+ /**
2015
+ * Render inline `@mention` file picker below the status bar.
2016
+ *
2017
+ * Mirrors the layout of `renderInlineAutocomplete` (separator → title →
2018
+ * items → footer) but shows file paths with their parent directory as
2019
+ * the description, and a `@` prefix instead of `/`.
2020
+ */
2021
+ renderInlineMentionPicker(startY, width) {
2022
+ const items = this.mentionItems;
2023
+ const maxVisible = Math.min(items.length, 8);
2024
+ let y = startY;
2025
+ // Separator line
2026
+ this.screen.horizontalLine(y++, '─', PRIMARY_COLOR);
2027
+ // Title
2028
+ this.screen.writeLine(y++, 'Add file to context (@mention)', PRIMARY_COLOR + style.bold);
2029
+ // Items: `path` + directory detail
2030
+ const visibleStart = Math.max(0, this.mentionIndex - maxVisible + 1);
2031
+ const visibleItems = items.slice(visibleStart, visibleStart + maxVisible);
2032
+ for (let i = 0; i < visibleItems.length; i++) {
2033
+ const item = visibleItems[i];
2034
+ const actualIndex = visibleStart + i;
2035
+ const isSelected = actualIndex === this.mentionIndex;
2036
+ const prefix = isSelected ? '► ' : ' ';
2037
+ const pathText = ('@' + item.label).padEnd(40);
2038
+ if (isSelected) {
2039
+ this.screen.write(0, y, prefix, PRIMARY_COLOR);
2040
+ this.screen.write(prefix.length, y, pathText, PRIMARY_COLOR + style.bold);
2041
+ this.screen.write(prefix.length + pathText.length, y, item.detail, fg.white);
2042
+ }
2043
+ else {
2044
+ this.screen.write(0, y, prefix, '');
2045
+ this.screen.write(prefix.length, y, pathText, fg.cyan);
2046
+ this.screen.write(prefix.length + pathText.length, y, item.detail, fg.gray);
2047
+ }
2048
+ y++;
2049
+ }
2050
+ // Footer
2051
+ const scrollInfo = items.length > maxVisible ? ` (${visibleStart + 1}-${visibleStart + visibleItems.length}/${items.length})` : '';
2052
+ this.screen.writeLine(y, `↑↓ navigate • Tab select • Esc cancel${scrollInfo}`, fg.gray);
2053
+ }
1857
2054
  /**
1858
2055
  * Render inline permission dialog
1859
2056
  */
@@ -2091,21 +2288,7 @@ export class App {
2091
2288
  // 8-bit gradient progress bar (right side, if max iterations known)
2092
2289
  if (this.agentMaxIterations > 0) {
2093
2290
  const barWidth = 14;
2094
- const progress = Math.min(this.agentIteration / this.agentMaxIterations, 1);
2095
- const filled = Math.round(progress * barWidth);
2096
- // Use block chars: █▓▒░ for gradient fill effect
2097
- const BLOCKS = ['░', '▒', '▓', '█'];
2098
- let bar = '';
2099
- for (let i = 0; i < barWidth; i++) {
2100
- if (i < filled - 1)
2101
- bar += '█';
2102
- else if (i === filled - 1)
2103
- bar += '▓';
2104
- else if (i === filled)
2105
- bar += '▒';
2106
- else
2107
- bar += '░';
2108
- }
2291
+ const bar = agentProgressBar(this.agentIteration, this.agentMaxIterations, barWidth);
2109
2292
  const barColored = PRIMARY_COLOR + bar + style.reset;
2110
2293
  const stepText = `${this.agentIteration}/${this.agentMaxIterations}`;
2111
2294
  const barX = width - barWidth - stepText.length - 3;
@@ -2128,51 +2311,6 @@ export class App {
2128
2311
  /**
2129
2312
  * Get color for action type
2130
2313
  */
2131
- getActionColor(type) {
2132
- const colors = {
2133
- 'read': fg.blue,
2134
- 'write': fg.green,
2135
- 'edit': fg.yellow,
2136
- 'delete': fg.red,
2137
- 'command': fg.magenta,
2138
- 'search': fg.cyan,
2139
- 'list': fg.white,
2140
- 'mkdir': fg.blue,
2141
- 'fetch': fg.cyan,
2142
- };
2143
- return colors[type] || fg.white;
2144
- }
2145
- /**
2146
- * Format action target for display
2147
- */
2148
- formatActionTarget(target, maxLen) {
2149
- if (target.includes('/')) {
2150
- const parts = target.split('/');
2151
- const filename = parts[parts.length - 1];
2152
- if (parts.length > 2) {
2153
- const short = `.../${parts[parts.length - 2]}/${filename}`;
2154
- return short.length > maxLen ? '...' + short.slice(-(maxLen - 3)) : short;
2155
- }
2156
- }
2157
- return target.length > maxLen ? '...' + target.slice(-(maxLen - 3)) : target;
2158
- }
2159
- /**
2160
- * Get action label for display
2161
- */
2162
- getActionLabel(type) {
2163
- const labels = {
2164
- 'read': 'Reading',
2165
- 'write': 'Creating',
2166
- 'edit': 'Editing',
2167
- 'delete': 'Deleting',
2168
- 'command': 'Running',
2169
- 'search': 'Searching',
2170
- 'list': 'Listing',
2171
- 'mkdir': 'Creating dir',
2172
- 'fetch': 'Fetching',
2173
- };
2174
- return labels[type] || type;
2175
- }
2176
2314
  /**
2177
2315
  * Render status bar
2178
2316
  */
@@ -2182,7 +2320,7 @@ export class App {
2182
2320
  if (this.notification) {
2183
2321
  const notifColor = this.notificationIsWarn ? '\x1b[38;5;208m' : PRIMARY_COLOR; // orange for warn
2184
2322
  const maxLen = width - 2;
2185
- const msg = this.notification.length > maxLen ? this.notification.slice(0, maxLen - 1) + '…' : this.notification;
2323
+ const msg = truncateNotification(this.notification, maxLen);
2186
2324
  this.screen.write(0, y, notifColor + ' ' + msg + style.reset);
2187
2325
  return;
2188
2326
  }
@@ -2192,7 +2330,7 @@ export class App {
2192
2330
  const modelName = status.model || '';
2193
2331
  const msgCount = `${this.messages.length} msg`;
2194
2332
  const tokenStr = stats && stats.totalTokens > 0
2195
- ? `${stats.totalTokens < 1000 ? stats.totalTokens : (stats.totalTokens / 1000).toFixed(1) + 'K'} tok`
2333
+ ? `${formatTokenCount(stats.totalTokens)} tok`
2196
2334
  : '';
2197
2335
  let leftX = 1;
2198
2336
  if (modelName) {
@@ -2218,18 +2356,16 @@ export class App {
2218
2356
  // Right: context-sensitive hints. While scrolled up, the "new
2219
2357
  // messages below" badge takes priority — it's the only signal that
2220
2358
  // the conversation moved on (addMessage no longer yanks the view).
2359
+ const rightText = statusBarRightHint({
2360
+ scrollOffset: this.scrollOffset,
2361
+ unseenWhileScrolled: this.unseenWhileScrolled,
2362
+ isStreaming: this.isStreaming,
2363
+ isLoading: this.isLoading,
2364
+ });
2221
2365
  if (this.scrollOffset > 0 && this.unseenWhileScrolled > 0) {
2222
- const badge = `↓ ${this.unseenWhileScrolled} new · PgDn `;
2223
- this.screen.write(width - badge.length, y, badge, PRIMARY_COLOR);
2366
+ this.screen.write(width - rightText.length, y, rightText, PRIMARY_COLOR);
2224
2367
  return;
2225
2368
  }
2226
- let rightText;
2227
- if (this.isStreaming || this.isLoading) {
2228
- rightText = 'Esc to stop ';
2229
- }
2230
- else {
2231
- rightText = '/help · ↑↓ history ';
2232
- }
2233
2369
  this.screen.write(width - rightText.length, y, rightText, fg.gray);
2234
2370
  }
2235
2371
  /**
@@ -2237,7 +2373,7 @@ export class App {
2237
2373
  */
2238
2374
  getVisibleMessages(height, width) {
2239
2375
  const allLines = [];
2240
- this.codeBlockCounter = 0; // Reset block counter for each render pass
2376
+ this.codeBlockCounter.current = 0; // Reset block counter for each render pass
2241
2377
  // Logo at the top, scrolls with content
2242
2378
  if (height >= 20) {
2243
2379
  const logoWidth = LOGO_LINES[0].length;
@@ -2255,365 +2391,36 @@ export class App {
2255
2391
  for (let i = 0; i < this.messages.length; i++) {
2256
2392
  const msg = this.messages[i];
2257
2393
  const cached = this.messageCache[i];
2258
- if (cached && cached.width === width && cached.startBlock === this.codeBlockCounter) {
2394
+ if (cached && cached.width === width && cached.startBlock === this.codeBlockCounter.current) {
2259
2395
  // Cache hit — preskoči formatiranje
2260
- this.codeBlockCounter += cached.blockCount;
2396
+ this.codeBlockCounter.current += cached.blockCount;
2261
2397
  allLines.push(...cached.lines);
2262
2398
  }
2263
2399
  else {
2264
2400
  // Cache miss — formatiraj i spremi
2265
- const startBlock = this.codeBlockCounter;
2401
+ const startBlock = this.codeBlockCounter.current;
2266
2402
  const msgLines = msg.role === 'welcome'
2267
- ? this.formatWelcomeMessage(msg.content)
2268
- : this.formatMessage(msg.role, msg.content, width);
2269
- const blockCount = this.codeBlockCounter - startBlock;
2403
+ ? formatWelcomeMessage(msg.content)
2404
+ : formatMessageFn(msg.role, msg.content, width, this.codeBlockCounter);
2405
+ const blockCount = this.codeBlockCounter.current - startBlock;
2270
2406
  this.messageCache[i] = { lines: msgLines, width, startBlock, blockCount };
2271
2407
  allLines.push(...msgLines);
2272
2408
  }
2273
2409
  }
2274
2410
  if (this.isStreaming && this.streamingContent) {
2275
- const streamLines = this.formatMessage('assistant', this.streamingContent + '▊', width);
2411
+ const streamLines = formatMessageFn('assistant', this.streamingContent + '▊', width, this.codeBlockCounter);
2276
2412
  allLines.push(...streamLines);
2277
2413
  }
2278
2414
  // Calculate visible window based on scroll offset
2279
2415
  const totalLines = allLines.length;
2280
- const maxScroll = Math.max(0, totalLines - height);
2281
- if (this.scrollOffset > maxScroll) {
2282
- this.scrollOffset = maxScroll;
2283
- }
2284
- const endIndex = totalLines - this.scrollOffset;
2285
- const startIndex = Math.max(0, endIndex - height);
2416
+ const { startIndex, endIndex, clampedScrollOffset } = scrollWindow({
2417
+ totalLines,
2418
+ height,
2419
+ scrollOffset: this.scrollOffset,
2420
+ });
2421
+ this.scrollOffset = clampedScrollOffset;
2286
2422
  return allLines.slice(startIndex, endIndex);
2287
2423
  }
2288
- /**
2289
- * Format message into lines with syntax highlighting for code blocks
2290
- */
2291
- formatWelcomeMessage(content) {
2292
- const lines = [];
2293
- const DIM = fg.rgb(80, 80, 80);
2294
- const LABEL = fg.rgb(100, 100, 100);
2295
- const SEP = DIM + ' · ' + style.reset;
2296
- for (const line of content.split('\n')) {
2297
- if (line.trim() === '') {
2298
- lines.push({ text: '', style: '' });
2299
- continue;
2300
- }
2301
- // Version line: "Codeep vX.X.X · Provider · Model"
2302
- if (line.startsWith('Codeep ')) {
2303
- const parts = line.split(' · ');
2304
- const colored = PRIMARY_COLOR + style.bold + (parts[0] || '') + style.reset
2305
- + SEP + fg.rgb(180, 180, 180) + (parts[1] || '') + style.reset
2306
- + SEP + fg.rgb(130, 130, 130) + (parts[2] || '') + style.reset;
2307
- lines.push({ text: colored, style: '', raw: true });
2308
- continue;
2309
- }
2310
- // Project line
2311
- if (/^\s+Project\s/.test(line)) {
2312
- const value = line.replace(/^\s+Project\s+/, '');
2313
- lines.push({ text: LABEL + ' Project ' + style.reset + fg.rgb(100, 180, 220) + value + style.reset, style: '', raw: true });
2314
- continue;
2315
- }
2316
- // Access line
2317
- if (/^\s+Access\s/.test(line)) {
2318
- const value = line.replace(/^\s+Access\s+/, '');
2319
- const parts = value.split(' · ');
2320
- const accessColored = fg.rgb(100, 200, 120) + style.bold + (parts[0] || '') + style.reset;
2321
- const rest = parts.slice(1).map(p => fg.rgb(80, 160, 100) + p + style.reset).join(SEP);
2322
- lines.push({ text: LABEL + ' Access ' + style.reset + accessColored + (rest ? SEP + rest : ''), style: '', raw: true });
2323
- continue;
2324
- }
2325
- // Mode line
2326
- if (/^\s+Mode\s/.test(line)) {
2327
- const value = line.replace(/^\s+Mode\s+/, '');
2328
- lines.push({ text: LABEL + ' Mode ' + style.reset + fg.rgb(160, 160, 160) + value + style.reset, style: '', raw: true });
2329
- continue;
2330
- }
2331
- // Agent Mode warning
2332
- if (line.includes('⚠')) {
2333
- lines.push({ text: ' ' + fg.rgb(220, 160, 40) + line.trim() + style.reset, style: '', raw: true });
2334
- continue;
2335
- }
2336
- // Shortcuts line
2337
- if (line.includes('/help')) {
2338
- const parts = line.trim().split(' · ');
2339
- const colored = parts.map(p => fg.rgb(150, 150, 150) + p.trim() + style.reset).join(DIM + ' · ' + style.reset);
2340
- lines.push({ text: ' ' + colored, style: '', raw: true });
2341
- continue;
2342
- }
2343
- lines.push({ text: line, style: '' });
2344
- }
2345
- lines.push({ text: '', style: '' });
2346
- return lines;
2347
- }
2348
- formatMessage(role, content, maxWidth) {
2349
- const lines = [];
2350
- // Role-specific prefix — user gets primary color bar, assistant gets dim header, system gets diamond
2351
- const contIndent = ' ';
2352
- let firstPrefix;
2353
- const firstStyle = '';
2354
- if (role === 'user') {
2355
- firstPrefix = PRIMARY_COLOR + '\u258c ' + style.reset;
2356
- }
2357
- else if (role === 'assistant') {
2358
- lines.push({ text: PRIMARY_COLOR + '\u254c\u254c' + style.reset + fg.rgb(120, 120, 120) + ' codeep' + style.reset, style: '', raw: true });
2359
- firstPrefix = ' ';
2360
- }
2361
- else {
2362
- firstPrefix = PRIMARY_COLOR + '\u25b8 ' + style.reset;
2363
- }
2364
- const codeBlockRegex = /```([^\n]*)\n([\s\S]*?)```/g;
2365
- let lastIndex = 0;
2366
- let match;
2367
- let isFirstLine = true;
2368
- while ((match = codeBlockRegex.exec(content)) !== null) {
2369
- const textBefore = content.slice(lastIndex, match.index);
2370
- if (textBefore) {
2371
- const prefix = isFirstLine ? firstPrefix : (role === 'user' ? contIndent : ' ');
2372
- const textLines = this.formatTextLines(textBefore, maxWidth, prefix, firstStyle, role === 'user' && isFirstLine);
2373
- lines.push(...textLines);
2374
- isFirstLine = false;
2375
- }
2376
- this.codeBlockCounter++;
2377
- const rawLang = (match[1] || 'text').trim();
2378
- let lang = rawLang;
2379
- if (rawLang.includes(':') || rawLang.includes('.')) {
2380
- lang = rawLang.split('.').pop() || rawLang;
2381
- }
2382
- lines.push(...this.formatCodeBlock(match[2], lang, maxWidth, this.codeBlockCounter));
2383
- lastIndex = match.index + match[0].length;
2384
- isFirstLine = false;
2385
- }
2386
- const textAfter = content.slice(lastIndex);
2387
- if (textAfter) {
2388
- const prefix = isFirstLine ? firstPrefix : (role === 'user' ? contIndent : ' ');
2389
- const textLines = this.formatTextLines(textAfter, maxWidth, prefix, firstStyle, role === 'user' && isFirstLine);
2390
- lines.push(...textLines);
2391
- }
2392
- lines.push({ text: '', style: '' });
2393
- return lines;
2394
- }
2395
- /**
2396
- * Apply inline markdown formatting (bold, italic, inline code) to a line
2397
- */
2398
- applyInlineMarkdown(text) {
2399
- let result = '';
2400
- let hasFormatting = false;
2401
- let i = 0;
2402
- while (i < text.length) {
2403
- // Inline code: `code`
2404
- if (text[i] === '`' && text[i + 1] !== '`') {
2405
- const end = text.indexOf('`', i + 1);
2406
- if (end !== -1) {
2407
- const code = text.slice(i + 1, end);
2408
- result += fg.rgb(209, 154, 102) + code + '\x1b[0m';
2409
- hasFormatting = true;
2410
- i = end + 1;
2411
- continue;
2412
- }
2413
- }
2414
- // Bold + italic: ***text***
2415
- if (text.slice(i, i + 3) === '***') {
2416
- const end = text.indexOf('***', i + 3);
2417
- if (end !== -1) {
2418
- const inner = text.slice(i + 3, end);
2419
- result += style.bold + style.italic + PRIMARY_COLOR + inner + '\x1b[0m';
2420
- hasFormatting = true;
2421
- i = end + 3;
2422
- continue;
2423
- }
2424
- }
2425
- // Bold: **text**
2426
- if (text.slice(i, i + 2) === '**') {
2427
- const end = text.indexOf('**', i + 2);
2428
- if (end !== -1) {
2429
- const inner = text.slice(i + 2, end);
2430
- result += style.bold + PRIMARY_COLOR + inner + '\x1b[0m';
2431
- hasFormatting = true;
2432
- i = end + 2;
2433
- continue;
2434
- }
2435
- }
2436
- // Italic: *text*
2437
- if (text[i] === '*' && text[i + 1] !== '*') {
2438
- const end = text.indexOf('*', i + 1);
2439
- if (end !== -1 && end > i + 1) {
2440
- const inner = text.slice(i + 1, end);
2441
- result += style.italic + inner + '\x1b[0m';
2442
- hasFormatting = true;
2443
- i = end + 1;
2444
- continue;
2445
- }
2446
- }
2447
- // Strikethrough: ~~text~~ — using the SGR strikethrough escape (\x1b[9m).
2448
- // Widely supported in modern terminals (iTerm2, Kitty, WezTerm, Alacritty,
2449
- // gnome-terminal, Windows Terminal). Falls back gracefully to the dim
2450
- // text colour on terminals that don't render the SGR.
2451
- if (text.slice(i, i + 2) === '~~') {
2452
- const end = text.indexOf('~~', i + 2);
2453
- if (end !== -1) {
2454
- const inner = text.slice(i + 2, end);
2455
- result += '\x1b[9m' + fg.rgb(140, 140, 140) + inner + '\x1b[0m';
2456
- hasFormatting = true;
2457
- i = end + 2;
2458
- continue;
2459
- }
2460
- }
2461
- result += text[i];
2462
- i++;
2463
- }
2464
- return { formatted: result, hasFormatting };
2465
- }
2466
- /**
2467
- * Format plain text lines with markdown support
2468
- */
2469
- formatTextLines(text, maxWidth, firstPrefix, firstStyle, rawPrefix = false) {
2470
- const lines = [];
2471
- const contentLines = text.split('\n');
2472
- for (let i = 0; i < contentLines.length; i++) {
2473
- const line = contentLines[i];
2474
- const prefix = i === 0 ? firstPrefix : ' ';
2475
- const prefixStyle = i === 0 ? firstStyle : '';
2476
- const isRaw = i === 0 ? rawPrefix : false;
2477
- // Heading: ## or ### etc.
2478
- const headingMatch = line.match(/^(#{1,6})\s+(.+)$/);
2479
- if (headingMatch) {
2480
- const level = headingMatch[1].length;
2481
- const headingText = headingMatch[2];
2482
- const headingColor = level <= 2 ? fg.rgb(97, 175, 239) : fg.rgb(198, 120, 221);
2483
- lines.push({
2484
- text: prefix + headingColor + style.bold + headingText + '\x1b[0m',
2485
- style: prefixStyle,
2486
- raw: true,
2487
- });
2488
- continue;
2489
- }
2490
- // Horizontal rule: --- or *** or ___
2491
- if (/^[-*_]{3,}\s*$/.test(line)) {
2492
- const ruleWidth = Math.min(maxWidth - 4, 40);
2493
- lines.push({
2494
- text: prefix + fg.gray + '─'.repeat(ruleWidth) + '\x1b[0m',
2495
- style: prefixStyle,
2496
- raw: true,
2497
- });
2498
- continue;
2499
- }
2500
- // Blockquote: `> text` — render with a left accent bar (PRIMARY_COLOR)
2501
- // and the body in a dimmer grey so it visually sits behind regular text.
2502
- // Strips one level of `> ` so nested quotes render with their own bar.
2503
- const quoteMatch = line.match(/^(\s*)>\s?(.*)$/);
2504
- if (quoteMatch) {
2505
- const indent = quoteMatch[1];
2506
- const quoteText = quoteMatch[2];
2507
- const { formatted, hasFormatting } = this.applyInlineMarkdown(quoteText);
2508
- const body = hasFormatting ? formatted : quoteText;
2509
- // Vertical bar + space, then dim grey body. Skip if the body is
2510
- // empty so an isolated `>` renders cleanly.
2511
- const barred = PRIMARY_COLOR + '│' + '\x1b[0m' + (body ? ' ' + fg.rgb(160, 160, 160) + body + '\x1b[0m' : '');
2512
- lines.push({
2513
- text: prefix + indent + barred,
2514
- style: prefixStyle,
2515
- raw: true,
2516
- });
2517
- continue;
2518
- }
2519
- // List items: - item or * item or numbered 1. item
2520
- const listMatch = line.match(/^(\s*)([-*]|\d+\.)\s+(.+)$/);
2521
- if (listMatch) {
2522
- const indent = listMatch[1];
2523
- const bullet = listMatch[2];
2524
- const content = listMatch[3];
2525
- const { formatted, hasFormatting } = this.applyInlineMarkdown(content);
2526
- const bulletChar = bullet === '-' || bullet === '*' ? '\u25b8' : bullet;
2527
- if (hasFormatting) {
2528
- lines.push({
2529
- text: prefix + indent + fg.gray + bulletChar + '\x1b[0m' + ' ' + formatted,
2530
- style: prefixStyle,
2531
- raw: true,
2532
- });
2533
- }
2534
- else {
2535
- lines.push({
2536
- text: prefix + indent + bulletChar + ' ' + content,
2537
- style: prefixStyle,
2538
- });
2539
- }
2540
- continue;
2541
- }
2542
- // Regular text with possible inline markdown
2543
- const { formatted, hasFormatting } = this.applyInlineMarkdown(line);
2544
- if (hasFormatting) {
2545
- // Use original (no-ANSI) line to measure and wrap, then apply markdown per segment
2546
- if (stringWidth(line) > maxWidth - prefix.length) {
2547
- const wrapped = this.wordWrap(line, maxWidth - prefix.length);
2548
- for (let j = 0; j < wrapped.length; j++) {
2549
- const { formatted: segFormatted } = this.applyInlineMarkdown(wrapped[j]);
2550
- lines.push({
2551
- text: (j === 0 ? prefix : ' ') + segFormatted,
2552
- style: j === 0 ? prefixStyle : '',
2553
- raw: true,
2554
- });
2555
- }
2556
- }
2557
- else {
2558
- lines.push({
2559
- text: prefix + formatted,
2560
- style: prefixStyle,
2561
- raw: true,
2562
- });
2563
- }
2564
- }
2565
- else {
2566
- // Plain text - word wrap as before
2567
- if (stringWidth(line) > maxWidth - prefix.length) {
2568
- const wrapped = this.wordWrap(line, maxWidth - prefix.length);
2569
- for (let j = 0; j < wrapped.length; j++) {
2570
- const lineIsRaw = j === 0 ? isRaw : false;
2571
- lines.push({
2572
- text: (j === 0 ? prefix : ' ') + wrapped[j],
2573
- style: j === 0 ? prefixStyle : '',
2574
- ...(lineIsRaw ? { raw: true } : {}),
2575
- });
2576
- }
2577
- }
2578
- else {
2579
- lines.push({
2580
- text: prefix + line,
2581
- style: prefixStyle,
2582
- ...(isRaw ? { raw: true } : {}),
2583
- });
2584
- }
2585
- }
2586
- }
2587
- return lines;
2588
- }
2589
- /**
2590
- * Format code block with syntax highlighting (no border)
2591
- */
2592
- formatCodeBlock(code, lang, maxWidth, blockNum) {
2593
- const lines = [];
2594
- const codeLines = code.split('\n');
2595
- // Remove trailing empty line if exists
2596
- if (codeLines.length > 0 && codeLines[codeLines.length - 1] === '') {
2597
- codeLines.pop();
2598
- }
2599
- // Language label with block number for /copy
2600
- const label = blockNum ? (lang ? ` ${lang} [${blockNum}]` : ` [${blockNum}]`) : (lang ? ' ' + lang : '');
2601
- if (label) {
2602
- lines.push({ text: label, style: SYNTAX.codeLang, raw: false });
2603
- }
2604
- // Code lines with highlighting and indent
2605
- for (const codeLine of codeLines) {
2606
- const highlighted = highlightCode(codeLine, lang);
2607
- lines.push({
2608
- text: ' ' + highlighted,
2609
- style: '',
2610
- raw: true // Don't apply additional styling, code is pre-highlighted
2611
- });
2612
- }
2613
- // Empty line after code block
2614
- lines.push({ text: '', style: '', raw: false });
2615
- return lines;
2616
- }
2617
2424
  /**
2618
2425
  * Render inline search screen
2619
2426
  */
@@ -2772,41 +2579,4 @@ export class App {
2772
2579
  return resultLine;
2773
2580
  }).join('\n');
2774
2581
  }
2775
- /**
2776
- * Word wrap
2777
- */
2778
- wordWrap(text, maxWidth) {
2779
- const words = text.split(' ');
2780
- const lines = [];
2781
- let currentLine = '';
2782
- for (const word of words) {
2783
- const wordW = stringWidth(word);
2784
- // Hard-break words wider than maxWidth (e.g. long file paths with no spaces)
2785
- if (wordW > maxWidth) {
2786
- if (currentLine) {
2787
- lines.push(currentLine);
2788
- currentLine = '';
2789
- }
2790
- // Slice the word into maxWidth chunks
2791
- let remaining = word;
2792
- while (stringWidth(remaining) > maxWidth) {
2793
- lines.push(remaining.slice(0, maxWidth));
2794
- remaining = remaining.slice(maxWidth);
2795
- }
2796
- currentLine = remaining;
2797
- continue;
2798
- }
2799
- if (stringWidth(currentLine) + wordW + 1 > maxWidth && currentLine) {
2800
- lines.push(currentLine);
2801
- currentLine = word;
2802
- }
2803
- else {
2804
- currentLine += (currentLine ? ' ' : '') + word;
2805
- }
2806
- }
2807
- if (currentLine) {
2808
- lines.push(currentLine);
2809
- }
2810
- return lines.length > 0 ? lines : [''];
2811
- }
2812
2582
  }