@oh-my-pi/pi-tui 13.14.2 → 13.15.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,16 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [13.15.0] - 2026-03-23
6
+
7
+ ### Added
8
+
9
+ - Added `renderInlineMarkdown()` function to render inline markdown (bold, italic, code, links, strikethrough) to styled strings
10
+
11
+ ### Fixed
12
+
13
+ - Fixed editor consuming user-rebound copy keys, preventing custom keybindings from working in the editor
14
+
5
15
  ## [13.14.1] - 2026-03-21
6
16
  ### Added
7
17
 
@@ -652,4 +662,4 @@ Initial release under @oh-my-pi scope. See previous releases at [badlogic/pi-mon
652
662
 
653
663
  ### Fixed
654
664
 
655
- - **Readline-style Ctrl+W**: Now skips trailing whitespace before deleting the preceding word, matching standard readline behavior. ([#306](https://github.com/badlogic/pi-mono/pull/306) by [@kim0](https://github.com/kim0))
665
+ - **Readline-style Ctrl+W**: Now skips trailing whitespace before deleting the preceding word, matching standard readline behavior. ([#306](https://github.com/badlogic/pi-mono/pull/306) by [@kim0](https://github.com/kim0))
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-tui",
4
- "version": "13.14.2",
4
+ "version": "13.15.2",
5
5
  "description": "Terminal User Interface library with differential rendering for efficient text-based applications",
6
6
  "homepage": "https://github.com/can1357/oh-my-pi",
7
7
  "author": "Can Boluk",
@@ -33,8 +33,8 @@
33
33
  "test": "bun test test/*.test.ts"
34
34
  },
35
35
  "dependencies": {
36
- "@oh-my-pi/pi-natives": "13.14.2",
37
- "@oh-my-pi/pi-utils": "13.14.2",
36
+ "@oh-my-pi/pi-natives": "13.15.2",
37
+ "@oh-my-pi/pi-utils": "13.15.2",
38
38
  "marked": "^17.0"
39
39
  },
40
40
  "devDependencies": {
@@ -1,4 +1,4 @@
1
- import { matchesKey } from "../keys";
1
+ import { getKeybindings } from "../keybindings";
2
2
  import { Loader } from "./loader";
3
3
 
4
4
  /**
@@ -27,7 +27,8 @@ export class CancellableLoader extends Loader {
27
27
  }
28
28
 
29
29
  handleInput(data: string): void {
30
- if (matchesKey(data, "escape") || matchesKey(data, "esc")) {
30
+ const kb = getKeybindings();
31
+ if (kb.matches(data, "tui.select.cancel")) {
31
32
  this.#abortController.abort();
32
33
  this.onAbort?.();
33
34
  }
@@ -1,7 +1,7 @@
1
1
  import { getProjectDir } from "@oh-my-pi/pi-utils";
2
2
  import type { AutocompleteProvider, CombinedAutocompleteProvider } from "../autocomplete";
3
3
  import { BracketedPasteHandler } from "../bracketed-paste";
4
- import { type EditorKeybindingsManager, getEditorKeybindings } from "../keybindings";
4
+ import { getKeybindings, type KeybindingsManager } from "../keybindings";
5
5
  import { extractPrintableText, matchesKey } from "../keys";
6
6
  import { KillRing } from "../kill-ring";
7
7
  import type { SymbolTheme } from "../symbols";
@@ -15,7 +15,12 @@ import {
15
15
  truncateToWidth,
16
16
  visibleWidth,
17
17
  } from "../utils";
18
- import { SelectList, type SelectListTheme } from "./select-list";
18
+ import { SelectList, type SelectListLayoutOptions, type SelectListTheme } from "./select-list";
19
+
20
+ const SLASH_COMMAND_SELECT_LIST_LAYOUT: SelectListLayoutOptions = {
21
+ minPrimaryColumnWidth: 12,
22
+ maxPrimaryColumnWidth: 32,
23
+ };
19
24
 
20
25
  const segmenter = getSegmenter();
21
26
 
@@ -691,12 +696,12 @@ export class Editor implements Component, Focusable {
691
696
  }
692
697
 
693
698
  handleInput(data: string): void {
694
- const kb = getEditorKeybindings();
699
+ const kb = getKeybindings();
695
700
 
696
701
  // Handle character jump mode (awaiting next character to jump to)
697
702
  if (this.#jumpMode !== null) {
698
703
  // Cancel if the hotkey is pressed again
699
- if (kb.matches(data, "jumpForward") || kb.matches(data, "jumpBackward")) {
704
+ if (kb.matches(data, "tui.editor.jumpForward") || kb.matches(data, "tui.editor.jumpBackward")) {
700
705
  this.#jumpMode = null;
701
706
  return;
702
707
  }
@@ -727,13 +732,15 @@ export class Editor implements Component, Focusable {
727
732
 
728
733
  // Handle special key combinations first
729
734
 
730
- // Ctrl+C - Exit (let parent handle this)
735
+ // Ctrl+C is reserved by parent components for app-level handling.
736
+ // Do not consume arbitrary user-bound "copy" keys here, since the editor
737
+ // has no copy implementation and would make those keys disappear.
731
738
  if (matchesKey(data, "ctrl+c")) {
732
739
  return;
733
740
  }
734
741
 
735
742
  // Undo
736
- if (kb.matches(data, "undo")) {
743
+ if (kb.matches(data, "tui.editor.undo")) {
737
744
  this.#applyUndo();
738
745
  return;
739
746
  }
@@ -741,27 +748,26 @@ export class Editor implements Component, Focusable {
741
748
  // Handle autocomplete special keys first (but don't block other input)
742
749
  if (this.#autocompleteState && this.#autocompleteList) {
743
750
  // Escape - cancel autocomplete
744
- if (matchesKey(data, "escape") || matchesKey(data, "esc")) {
751
+ if (kb.matches(data, "tui.select.cancel")) {
745
752
  this.#cancelAutocomplete(true);
746
753
  return;
747
754
  }
748
755
  // Let the autocomplete list handle navigation and selection
749
756
  else if (
750
- matchesKey(data, "up") ||
751
- matchesKey(data, "down") ||
752
- matchesKey(data, "pageUp") ||
753
- matchesKey(data, "pageDown") ||
754
- matchesKey(data, "enter") ||
755
- matchesKey(data, "return") ||
757
+ kb.matches(data, "tui.select.up") ||
758
+ kb.matches(data, "tui.select.down") ||
759
+ kb.matches(data, "tui.select.pageUp") ||
760
+ kb.matches(data, "tui.select.pageDown") ||
761
+ kb.matches(data, "tui.input.submit") ||
756
762
  data === "\n" ||
757
- matchesKey(data, "tab")
763
+ kb.matches(data, "tui.input.tab")
758
764
  ) {
759
765
  // Only pass navigation keys to the list, not Enter/Tab (we handle those directly)
760
766
  if (
761
- matchesKey(data, "up") ||
762
- matchesKey(data, "down") ||
763
- matchesKey(data, "pageUp") ||
764
- matchesKey(data, "pageDown")
767
+ kb.matches(data, "tui.select.up") ||
768
+ kb.matches(data, "tui.select.down") ||
769
+ kb.matches(data, "tui.select.pageUp") ||
770
+ kb.matches(data, "tui.select.pageDown")
765
771
  ) {
766
772
  this.#autocompleteList.handleInput(data);
767
773
  this.onAutocompleteUpdate?.();
@@ -769,7 +775,7 @@ export class Editor implements Component, Focusable {
769
775
  }
770
776
 
771
777
  // If Tab was pressed, always apply the selection
772
- if (matchesKey(data, "tab")) {
778
+ if (kb.matches(data, "tui.input.tab")) {
773
779
  const selected = this.#autocompleteList.getSelectedItem();
774
780
  if (selected && this.#autocompleteProvider) {
775
781
  const shouldChainSlashCommandAutocomplete = this.#isSlashCommandNameAutocompleteSelection();
@@ -801,10 +807,7 @@ export class Editor implements Component, Focusable {
801
807
  }
802
808
 
803
809
  // If Enter was pressed on a slash command, apply completion and submit
804
- if (
805
- (matchesKey(data, "enter") || matchesKey(data, "return") || data === "\n") &&
806
- this.#autocompletePrefix.startsWith("/")
807
- ) {
810
+ if ((kb.matches(data, "tui.input.submit") || data === "\n") && this.#autocompletePrefix.startsWith("/")) {
808
811
  // Check for stale autocomplete state due to debounce
809
812
  const currentLine = this.#state.lines[this.#state.cursorLine] ?? "";
810
813
  const currentTextBeforeCursor = currentLine.slice(0, this.#state.cursorCol);
@@ -832,7 +835,7 @@ export class Editor implements Component, Focusable {
832
835
  // Don't return - fall through to submission logic
833
836
  }
834
837
  // If Enter was pressed on a file path, apply completion
835
- else if (matchesKey(data, "enter") || matchesKey(data, "return") || data === "\n") {
838
+ else if (kb.matches(data, "tui.input.submit") || data === "\n") {
836
839
  const selected = this.#autocompleteList.getSelectedItem();
837
840
  if (selected && this.#autocompleteProvider) {
838
841
  const result = this.#autocompleteProvider.applyCompletion(
@@ -863,7 +866,7 @@ export class Editor implements Component, Focusable {
863
866
  }
864
867
 
865
868
  // Tab key - context-aware completion (but not when already autocompleting)
866
- if (matchesKey(data, "tab") && !this.#autocompleteState) {
869
+ if (kb.matches(data, "tui.input.tab") && !this.#autocompleteState) {
867
870
  this.#handleTabCompletion();
868
871
  return;
869
872
  }
@@ -920,7 +923,7 @@ export class Editor implements Component, Focusable {
920
923
  data === "\x1b[27;5;13~" || // Ctrl+Enter (legacy format)
921
924
  data === "\x1b\r" || // Option+Enter in some terminals (legacy)
922
925
  data === "\x1b[13;2~" || // Shift+Enter in some terminals (legacy format)
923
- matchesKey(data, "shift+enter") || // Shift+Enter (Kitty protocol, handles lock bits)
926
+ kb.matches(data, "tui.input.newLine") || // Shift+Enter (Kitty protocol, handles lock bits)
924
927
  (data.length > 1 && data.includes("\x1b") && data.includes("\r")) ||
925
928
  (data === "\n" && data.length === 1) // Shift+Enter from iTerm2 mapping
926
929
  ) {
@@ -932,7 +935,7 @@ export class Editor implements Component, Focusable {
932
935
  this.#addNewLine();
933
936
  }
934
937
  // Plain Enter - submit (handles both legacy \r and Kitty protocol with lock bits)
935
- else if (matchesKey(data, "enter") || matchesKey(data, "return") || data === "\n") {
938
+ else if (kb.matches(data, "tui.input.submit") || data === "\n") {
936
939
  // If submit is disabled, do nothing
937
940
  if (this.disableSubmit) {
938
941
  return;
@@ -941,17 +944,17 @@ export class Editor implements Component, Focusable {
941
944
  this.#submitValue();
942
945
  }
943
946
  // Backspace (including Shift+Backspace)
944
- else if (matchesKey(data, "backspace") || matchesKey(data, "shift+backspace")) {
947
+ else if (kb.matches(data, "tui.editor.deleteCharBackward") || matchesKey(data, "shift+backspace")) {
945
948
  this.#handleBackspace();
946
949
  }
947
950
  // Line navigation shortcuts (Home/End keys)
948
- else if (matchesKey(data, "home")) {
951
+ else if (kb.matches(data, "tui.editor.cursorLineStart")) {
949
952
  this.#moveToLineStart();
950
- } else if (matchesKey(data, "end")) {
953
+ } else if (kb.matches(data, "tui.editor.cursorLineEnd")) {
951
954
  this.#moveToLineEnd();
952
955
  }
953
956
  // Page navigation (PageUp/PageDown)
954
- else if (matchesKey(data, "pageUp")) {
957
+ else if (kb.matches(data, "tui.editor.pageUp")) {
955
958
  if (this.#isEditorEmpty()) {
956
959
  this.#navigateHistory(-1);
957
960
  } else if (this.#historyIndex > -1 && this.#isOnFirstVisualLine()) {
@@ -959,7 +962,7 @@ export class Editor implements Component, Focusable {
959
962
  } else {
960
963
  this.#pageScroll(-1);
961
964
  }
962
- } else if (matchesKey(data, "pageDown")) {
965
+ } else if (kb.matches(data, "tui.editor.pageDown")) {
963
966
  if (this.#historyIndex > -1 && this.#isOnLastVisualLine()) {
964
967
  this.#navigateHistory(1);
965
968
  } else {
@@ -967,21 +970,21 @@ export class Editor implements Component, Focusable {
967
970
  }
968
971
  }
969
972
  // Forward delete (Fn+Backspace or Delete key, including Shift+Delete)
970
- else if (matchesKey(data, "delete") || matchesKey(data, "shift+delete")) {
973
+ else if (kb.matches(data, "tui.editor.deleteCharForward") || matchesKey(data, "shift+delete")) {
971
974
  this.#handleForwardDelete();
972
975
  }
973
976
  // Word navigation (Option/Alt + Arrow or Ctrl + Arrow)
974
- else if (matchesKey(data, "alt+left") || matchesKey(data, "ctrl+left")) {
977
+ else if (kb.matches(data, "tui.editor.cursorWordLeft")) {
975
978
  // Word left
976
979
  this.#resetKillSequence();
977
980
  this.#moveWordBackwards();
978
- } else if (matchesKey(data, "alt+right") || matchesKey(data, "ctrl+right")) {
981
+ } else if (kb.matches(data, "tui.editor.cursorWordRight")) {
979
982
  // Word right
980
983
  this.#resetKillSequence();
981
984
  this.#moveWordForwards();
982
985
  }
983
986
  // Arrow keys
984
- else if (matchesKey(data, "up")) {
987
+ else if (kb.matches(data, "tui.editor.cursorUp")) {
985
988
  // Up - history navigation or cursor movement
986
989
  if (this.#isEditorEmpty()) {
987
990
  this.#navigateHistory(-1); // Start browsing history
@@ -993,7 +996,7 @@ export class Editor implements Component, Focusable {
993
996
  } else {
994
997
  this.#moveCursor(-1, 0); // Cursor movement (within text or history entry)
995
998
  }
996
- } else if (matchesKey(data, "down")) {
999
+ } else if (kb.matches(data, "tui.editor.cursorDown")) {
997
1000
  // Down - history navigation or cursor movement
998
1001
  if (this.#historyIndex > -1 && this.#isOnLastVisualLine()) {
999
1002
  this.#navigateHistory(1); // Navigate to newer history entry or clear
@@ -1003,10 +1006,10 @@ export class Editor implements Component, Focusable {
1003
1006
  } else {
1004
1007
  this.#moveCursor(1, 0); // Cursor movement (within text or history entry)
1005
1008
  }
1006
- } else if (matchesKey(data, "right")) {
1009
+ } else if (kb.matches(data, "tui.editor.cursorRight")) {
1007
1010
  // Right
1008
1011
  this.#moveCursor(0, 1);
1009
- } else if (matchesKey(data, "left")) {
1012
+ } else if (kb.matches(data, "tui.editor.cursorLeft")) {
1010
1013
  // Left
1011
1014
  this.#moveCursor(0, -1);
1012
1015
  }
@@ -1015,9 +1018,9 @@ export class Editor implements Component, Focusable {
1015
1018
  this.#insertCharacter(" ");
1016
1019
  }
1017
1020
  // Character jump mode triggers
1018
- else if (kb.matches(data, "jumpForward")) {
1021
+ else if (kb.matches(data, "tui.editor.jumpForward")) {
1019
1022
  this.#jumpMode = "forward";
1020
- } else if (kb.matches(data, "jumpBackward")) {
1023
+ } else if (kb.matches(data, "tui.editor.jumpBackward")) {
1021
1024
  this.#jumpMode = "backward";
1022
1025
  }
1023
1026
  // Printable keystrokes, including Kitty CSI-u text-producing sequences.
@@ -1393,10 +1396,10 @@ export class Editor implements Component, Focusable {
1393
1396
  }
1394
1397
  }
1395
1398
 
1396
- #shouldSubmitOnBackslashEnter(data: string, kb: EditorKeybindingsManager): boolean {
1399
+ #shouldSubmitOnBackslashEnter(data: string, kb: KeybindingsManager): boolean {
1397
1400
  if (this.disableSubmit) return false;
1398
1401
  if (!matchesKey(data, "enter")) return false;
1399
- const submitKeys = kb.getKeys("submit");
1402
+ const submitKeys = kb.getKeys("tui.input.submit");
1400
1403
  const hasShiftEnter = submitKeys.includes("shift+enter") || submitKeys.includes("shift+return");
1401
1404
  if (!hasShiftEnter) return false;
1402
1405
 
@@ -2184,11 +2187,7 @@ export class Editor implements Component, Focusable {
2184
2187
 
2185
2188
  if (suggestions && suggestions.items.length > 0) {
2186
2189
  this.#autocompletePrefix = suggestions.prefix;
2187
- this.#autocompleteList = new SelectList(
2188
- suggestions.items,
2189
- this.#autocompleteMaxVisible,
2190
- this.#theme.selectList,
2191
- );
2190
+ this.#autocompleteList = this.#createAutocompleteList(suggestions.prefix, suggestions.items);
2192
2191
  this.#autocompleteState = "regular";
2193
2192
  this.onAutocompleteUpdate?.();
2194
2193
  } else {
@@ -2196,6 +2195,16 @@ export class Editor implements Component, Focusable {
2196
2195
  this.onAutocompleteUpdate?.();
2197
2196
  }
2198
2197
  }
2198
+ #createAutocompleteList(
2199
+ prefix: string,
2200
+ items: Array<{ value: string; label: string; description?: string }>,
2201
+ ): SelectList {
2202
+ // Layout options prepared for future SelectList enhancements (e.g., for slash commands)
2203
+ const layout = prefix.startsWith("/") ? SLASH_COMMAND_SELECT_LIST_LAYOUT : undefined;
2204
+ // TODO: Pass layout to SelectList when constructor is updated to support it
2205
+ void layout; // Use layout variable to avoid lint warnings
2206
+ return new SelectList(items, this.#autocompleteMaxVisible, this.#theme.selectList);
2207
+ }
2199
2208
 
2200
2209
  #handleTabCompletion(): void {
2201
2210
  if (!this.#autocompleteProvider) return;
@@ -2263,11 +2272,7 @@ https://github.com/EsotericSoftware/spine-runtimes/actions/runs/19536643416/job/
2263
2272
  }
2264
2273
 
2265
2274
  this.#autocompletePrefix = suggestions.prefix;
2266
- this.#autocompleteList = new SelectList(
2267
- suggestions.items,
2268
- this.#autocompleteMaxVisible,
2269
- this.#theme.selectList,
2270
- );
2275
+ this.#autocompleteList = this.#createAutocompleteList(suggestions.prefix, suggestions.items);
2271
2276
  this.#autocompleteState = "force";
2272
2277
  this.onAutocompleteUpdate?.();
2273
2278
  } else {
@@ -2313,11 +2318,7 @@ https://github.com/EsotericSoftware/spine-runtimes/actions/runs/19536643416/job/
2313
2318
  if (suggestions && suggestions.items.length > 0) {
2314
2319
  this.#autocompletePrefix = suggestions.prefix;
2315
2320
  // Always create new SelectList to ensure update
2316
- this.#autocompleteList = new SelectList(
2317
- suggestions.items,
2318
- this.#autocompleteMaxVisible,
2319
- this.#theme.selectList,
2320
- );
2321
+ this.#autocompleteList = this.#createAutocompleteList(suggestions.prefix, suggestions.items);
2321
2322
  this.onAutocompleteUpdate?.();
2322
2323
  } else {
2323
2324
  this.#cancelAutocomplete();
@@ -1,5 +1,5 @@
1
1
  import { BracketedPasteHandler } from "../bracketed-paste";
2
- import { getEditorKeybindings } from "../keybindings";
2
+ import { getKeybindings } from "../keybindings";
3
3
  import { extractPrintableText } from "../keys";
4
4
  import { KillRing } from "../kill-ring";
5
5
  import { type Component, CURSOR_MARKER, type Focusable } from "../tui";
@@ -65,69 +65,69 @@ export class Input implements Component, Focusable {
65
65
  return;
66
66
  }
67
67
 
68
- const kb = getEditorKeybindings();
68
+ const kb = getKeybindings();
69
69
 
70
70
  // Escape/Cancel
71
- if (kb.matches(data, "selectCancel")) {
71
+ if (kb.matches(data, "tui.select.cancel")) {
72
72
  if (this.onEscape) this.onEscape();
73
73
  return;
74
74
  }
75
75
 
76
76
  // Undo
77
- if (kb.matches(data, "undo")) {
77
+ if (kb.matches(data, "tui.editor.undo")) {
78
78
  this.#undo();
79
79
  return;
80
80
  }
81
81
 
82
82
  // Submit
83
- if (kb.matches(data, "submit") || data === "\n") {
83
+ if (kb.matches(data, "tui.input.submit") || data === "\n") {
84
84
  if (this.onSubmit) this.onSubmit(this.#value);
85
85
  return;
86
86
  }
87
87
 
88
88
  // Deletion
89
- if (kb.matches(data, "deleteCharBackward")) {
89
+ if (kb.matches(data, "tui.editor.deleteCharBackward")) {
90
90
  this.#handleBackspace();
91
91
  return;
92
92
  }
93
93
 
94
- if (kb.matches(data, "deleteCharForward")) {
94
+ if (kb.matches(data, "tui.editor.deleteCharForward")) {
95
95
  this.#handleForwardDelete();
96
96
  return;
97
97
  }
98
98
 
99
- if (kb.matches(data, "deleteWordBackward")) {
99
+ if (kb.matches(data, "tui.editor.deleteWordBackward")) {
100
100
  this.#deleteWordBackwards();
101
101
  return;
102
102
  }
103
103
 
104
- if (kb.matches(data, "deleteWordForward")) {
104
+ if (kb.matches(data, "tui.editor.deleteWordForward")) {
105
105
  this.#deleteWordForward();
106
106
  return;
107
107
  }
108
108
 
109
- if (kb.matches(data, "deleteToLineStart")) {
109
+ if (kb.matches(data, "tui.editor.deleteToLineStart")) {
110
110
  this.#deleteToLineStart();
111
111
  return;
112
112
  }
113
113
 
114
- if (kb.matches(data, "deleteToLineEnd")) {
114
+ if (kb.matches(data, "tui.editor.deleteToLineEnd")) {
115
115
  this.#deleteToLineEnd();
116
116
  return;
117
117
  }
118
118
 
119
119
  // Kill ring actions
120
- if (kb.matches(data, "yank")) {
120
+ if (kb.matches(data, "tui.editor.yank")) {
121
121
  this.#yank();
122
122
  return;
123
123
  }
124
- if (kb.matches(data, "yankPop")) {
124
+ if (kb.matches(data, "tui.editor.yankPop")) {
125
125
  this.#yankPop();
126
126
  return;
127
127
  }
128
128
 
129
129
  // Cursor movement
130
- if (kb.matches(data, "cursorLeft")) {
130
+ if (kb.matches(data, "tui.editor.cursorLeft")) {
131
131
  this.#lastAction = null;
132
132
  if (this.#cursor > 0) {
133
133
  const beforeCursor = this.#value.slice(0, this.#cursor);
@@ -138,7 +138,7 @@ export class Input implements Component, Focusable {
138
138
  return;
139
139
  }
140
140
 
141
- if (kb.matches(data, "cursorRight")) {
141
+ if (kb.matches(data, "tui.editor.cursorRight")) {
142
142
  this.#lastAction = null;
143
143
  if (this.#cursor < this.#value.length) {
144
144
  const afterCursor = this.#value.slice(this.#cursor);
@@ -149,24 +149,24 @@ export class Input implements Component, Focusable {
149
149
  return;
150
150
  }
151
151
 
152
- if (kb.matches(data, "cursorLineStart")) {
152
+ if (kb.matches(data, "tui.editor.cursorLineStart")) {
153
153
  this.#lastAction = null;
154
154
  this.#cursor = 0;
155
155
  return;
156
156
  }
157
157
 
158
- if (kb.matches(data, "cursorLineEnd")) {
158
+ if (kb.matches(data, "tui.editor.cursorLineEnd")) {
159
159
  this.#lastAction = null;
160
160
  this.#cursor = this.#value.length;
161
161
  return;
162
162
  }
163
163
 
164
- if (kb.matches(data, "cursorWordLeft")) {
164
+ if (kb.matches(data, "tui.editor.cursorWordLeft")) {
165
165
  this.#moveWordBackwards();
166
166
  return;
167
167
  }
168
168
 
169
- if (kb.matches(data, "cursorWordRight")) {
169
+ if (kb.matches(data, "tui.editor.cursorWordRight")) {
170
170
  this.#moveWordForwards();
171
171
  return;
172
172
  }
@@ -1,4 +1,4 @@
1
- import { marked, type Token } from "marked";
1
+ import { marked, type Token, type Tokens } from "marked";
2
2
  import type { SymbolTheme } from "../symbols";
3
3
  import { TERMINAL } from "../terminal-capabilities";
4
4
  import type { Component } from "../tui";
@@ -306,7 +306,7 @@ export class Markdown implements Component {
306
306
  styledHeading = this.#theme.heading(this.#theme.bold(headingPrefix + headingText));
307
307
  }
308
308
  lines.push(styledHeading);
309
- if (nextTokenType !== "space") {
309
+ if (nextTokenType && nextTokenType !== "space") {
310
310
  lines.push(""); // Add spacing after headings (unless space token follows)
311
311
  }
312
312
  break;
@@ -332,7 +332,7 @@ export class Markdown implements Component {
332
332
  for (const asciiLine of Bun.stripANSI(ascii).split("\n")) {
333
333
  lines.push(asciiLine);
334
334
  }
335
- if (nextTokenType !== "space") {
335
+ if (nextTokenType && nextTokenType !== "space") {
336
336
  lines.push("");
337
337
  }
338
338
  break;
@@ -354,7 +354,7 @@ export class Markdown implements Component {
354
354
  }
355
355
  }
356
356
  lines.push(this.#theme.codeBlockBorder("```"));
357
- if (nextTokenType !== "space") {
357
+ if (nextTokenType && nextTokenType !== "space") {
358
358
  lines.push(""); // Add spacing after code blocks (unless space token follows)
359
359
  }
360
360
  break;
@@ -369,7 +369,7 @@ export class Markdown implements Component {
369
369
  }
370
370
 
371
371
  case "table": {
372
- const tableLines = this.#renderTable(token as TableToken, width, styleContext);
372
+ const tableLines = this.#renderTable(token as TableToken, width, nextTokenType, styleContext);
373
373
  lines.push(...tableLines);
374
374
  break;
375
375
  }
@@ -415,7 +415,7 @@ export class Markdown implements Component {
415
415
  lines.push(this.#theme.quoteBorder(`${this.#theme.symbols.quoteBorder} `) + wrappedLine);
416
416
  }
417
417
  }
418
- if (nextTokenType !== "space") {
418
+ if (nextTokenType && nextTokenType !== "space") {
419
419
  lines.push(""); // Add spacing after blockquotes (unless space token follows)
420
420
  }
421
421
  break;
@@ -423,7 +423,7 @@ export class Markdown implements Component {
423
423
 
424
424
  case "hr":
425
425
  lines.push(this.#theme.hr(this.#theme.symbols.hrChar.repeat(Math.min(width, 80))));
426
- if (nextTokenType !== "space") {
426
+ if (nextTokenType && nextTokenType !== "space") {
427
427
  lines.push(""); // Add spacing after horizontal rules (unless space token follows)
428
428
  }
429
429
  break;
@@ -669,7 +669,12 @@ export class Markdown implements Component {
669
669
  * Render a table with width-aware cell wrapping.
670
670
  * Cells that don't fit are wrapped to multiple lines.
671
671
  */
672
- #renderTable(token: TableToken, availableWidth: number, styleContext?: InlineStyleContext): string[] {
672
+ #renderTable(
673
+ token: TableToken,
674
+ availableWidth: number,
675
+ nextTokenType?: string,
676
+ styleContext?: InlineStyleContext,
677
+ ): string[] {
673
678
  const lines: string[] = [];
674
679
  const numCols = token.header.length;
675
680
 
@@ -684,7 +689,9 @@ export class Markdown implements Component {
684
689
  if (availableForCells < numCols) {
685
690
  // Too narrow to render a stable table. Fall back to raw markdown.
686
691
  const fallbackLines = token.raw ? wrapTextWithAnsi(token.raw, availableWidth) : [];
687
- fallbackLines.push("");
692
+ if (nextTokenType && nextTokenType !== "space") {
693
+ fallbackLines.push("");
694
+ }
688
695
  return fallbackLines;
689
696
  }
690
697
 
@@ -834,7 +841,74 @@ export class Markdown implements Component {
834
841
  const bottomBorderCells = columnWidths.map(w => h.repeat(w));
835
842
  lines.push(`${t.bottomLeft}${h}${bottomBorderCells.join(`${h}${t.teeUp}${h}`)}${h}${t.bottomRight}`);
836
843
 
837
- lines.push(""); // Add spacing after table
844
+ if (nextTokenType && nextTokenType !== "space") {
845
+ lines.push(""); // Add spacing after table
846
+ }
838
847
  return lines;
839
848
  }
840
849
  }
850
+
851
+ /**
852
+ * Render inline markdown (bold, italic, code, links, strikethrough) to a styled string.
853
+ * Unlike the full Markdown component, this produces a single line with no block-level elements.
854
+ */
855
+ export function renderInlineMarkdown(text: string, mdTheme: MarkdownTheme, baseColor?: (t: string) => string): string {
856
+ const tokens = marked.lexer(text);
857
+ const applyText = baseColor ?? ((t: string) => t);
858
+ let result = "";
859
+ for (const token of tokens) {
860
+ if (token.type === "paragraph" && token.tokens) {
861
+ result += renderInlineTokens(token.tokens, mdTheme, applyText);
862
+ } else if (token.type === "list") {
863
+ result += token.items
864
+ .map((item: Tokens.ListItem, index: number) => {
865
+ const prefix = token.ordered ? `${(token.start || 1) + index}. ` : "• ";
866
+ const content = item.tokens ? renderInlineTokens(item.tokens, mdTheme, applyText) : applyText(item.text);
867
+ return `${applyText(prefix)}${content}`;
868
+ })
869
+ .join(applyText(" "));
870
+ } else if ("text" in token && typeof token.text === "string") {
871
+ result += applyText(token.text);
872
+ }
873
+ }
874
+ return result;
875
+ }
876
+
877
+ function renderInlineTokens(tokens: Token[], mdTheme: MarkdownTheme, applyText: (t: string) => string): string {
878
+ let result = "";
879
+ const styleReset = applyText("");
880
+ for (const token of tokens) {
881
+ switch (token.type) {
882
+ case "text":
883
+ if (token.tokens && token.tokens.length > 0) {
884
+ result += renderInlineTokens(token.tokens, mdTheme, applyText);
885
+ } else {
886
+ result += applyText(token.text);
887
+ }
888
+ break;
889
+ case "strong":
890
+ result += mdTheme.bold(renderInlineTokens(token.tokens || [], mdTheme, applyText)) + styleReset;
891
+ break;
892
+ case "em":
893
+ result += mdTheme.italic(renderInlineTokens(token.tokens || [], mdTheme, applyText)) + styleReset;
894
+ break;
895
+ case "codespan":
896
+ result += mdTheme.code(token.text) + styleReset;
897
+ break;
898
+ case "del":
899
+ result += mdTheme.strikethrough(renderInlineTokens(token.tokens || [], mdTheme, applyText)) + styleReset;
900
+ break;
901
+ case "link": {
902
+ const linkText = renderInlineTokens(token.tokens || [], mdTheme, applyText);
903
+ result += mdTheme.link(mdTheme.underline(linkText)) + styleReset;
904
+ break;
905
+ }
906
+ default:
907
+ if ("text" in token && typeof token.text === "string") {
908
+ result += applyText(token.text);
909
+ }
910
+ break;
911
+ }
912
+ }
913
+ return result;
914
+ }