@visual-z/xeno 0.1.3 → 0.2.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 (148) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/README.md +143 -33
  3. package/docs/install.md +18 -6
  4. package/docs/providers.md +147 -0
  5. package/package.json +8 -2
  6. package/src/apps/cli.ts +42 -14
  7. package/src/apps/commands.ts +32 -4
  8. package/src/apps/completions.ts +9 -6
  9. package/src/apps/export-cli.ts +24 -0
  10. package/src/apps/extension-ui.ts +330 -0
  11. package/src/apps/input.ts +16 -1
  12. package/src/apps/model-catalog.ts +42 -0
  13. package/src/apps/project-trust.ts +49 -0
  14. package/src/apps/render.ts +1 -0
  15. package/src/apps/resource-browser.ts +75 -0
  16. package/src/apps/resources.ts +25 -0
  17. package/src/apps/session-browser.ts +113 -0
  18. package/src/apps/session-picker.ts +55 -0
  19. package/src/apps/shell.ts +1177 -378
  20. package/src/apps/startup.ts +400 -268
  21. package/src/core/action-defaults.ts +306 -0
  22. package/src/core/attachment-input.ts +17 -0
  23. package/src/core/catalog-merge.ts +19 -0
  24. package/src/core/event-validation.ts +19 -2
  25. package/src/core/events.ts +45 -2
  26. package/src/core/export.ts +6 -2
  27. package/src/core/extension-flags.ts +14 -0
  28. package/src/core/images.ts +46 -0
  29. package/src/core/json-event.ts +2 -0
  30. package/src/core/keymap.ts +43 -119
  31. package/src/core/model-replay.ts +22 -0
  32. package/src/core/model-scope.ts +53 -0
  33. package/src/core/packages.ts +47 -0
  34. package/src/core/progress.ts +9 -0
  35. package/src/core/project.ts +21 -3
  36. package/src/core/prompts.ts +33 -5
  37. package/src/core/queue.ts +18 -0
  38. package/src/core/resource-selection.ts +36 -0
  39. package/src/{runtime → core}/resume.ts +15 -22
  40. package/src/core/session-archive.ts +4 -0
  41. package/src/core/session-browser.ts +82 -0
  42. package/src/core/session-entry.ts +56 -0
  43. package/src/core/session-position.ts +26 -0
  44. package/src/core/settings.ts +169 -154
  45. package/src/core/theme.ts +15 -6
  46. package/src/core/thinking.ts +26 -0
  47. package/src/core/tokens.ts +2 -0
  48. package/src/core/transcript.ts +15 -5
  49. package/src/runtime/auth.ts +116 -11
  50. package/src/runtime/catalog.ts +54 -151
  51. package/src/runtime/clipboard.ts +114 -0
  52. package/src/runtime/extension-loader.ts +37 -0
  53. package/src/runtime/extension-ui.ts +55 -0
  54. package/src/runtime/extensions.ts +273 -0
  55. package/src/runtime/images.ts +76 -0
  56. package/src/runtime/loop.ts +213 -83
  57. package/src/runtime/model-config.ts +232 -0
  58. package/src/runtime/model-data.LICENSE +27 -0
  59. package/src/runtime/model-data.ts +21 -0
  60. package/src/runtime/model-replay.ts +10 -0
  61. package/src/runtime/model-scope.ts +20 -0
  62. package/src/runtime/model-sources.ts +151 -0
  63. package/src/runtime/model.ts +74 -11
  64. package/src/runtime/models-fetch.ts +6 -104
  65. package/src/runtime/oauth.ts +4 -3
  66. package/src/runtime/openai-responses.ts +12 -4
  67. package/src/runtime/openai.ts +13 -4
  68. package/src/runtime/package-manager.ts +125 -0
  69. package/src/runtime/pi-provider.ts +235 -0
  70. package/src/runtime/progress.ts +26 -0
  71. package/src/runtime/provider-access.ts +21 -0
  72. package/src/runtime/provider-protocols.ts +29 -0
  73. package/src/runtime/provider-request.ts +73 -0
  74. package/src/runtime/provider.ts +26 -6
  75. package/src/runtime/rich-text.ts +36 -0
  76. package/src/runtime/tools.ts +41 -5
  77. package/src/store/command.ts +7 -1
  78. package/src/store/context-files.ts +1 -1
  79. package/src/store/log.ts +65 -0
  80. package/src/store/models.ts +28 -9
  81. package/src/store/observations.ts +4 -2
  82. package/src/store/packages.ts +98 -0
  83. package/src/store/recovery.ts +8 -0
  84. package/src/store/resource-index.ts +170 -0
  85. package/src/store/resources.ts +75 -142
  86. package/src/store/session-archive.ts +162 -0
  87. package/src/store/session-browser.ts +68 -0
  88. package/src/store/session-position.ts +27 -0
  89. package/src/store/sessions.ts +87 -66
  90. package/src/store/trust.ts +13 -0
  91. package/src/store/workspace.ts +6 -0
  92. package/src/tui/chat-viewport.ts +47 -0
  93. package/src/tui/editor.ts +62 -15
  94. package/src/tui/host.ts +50 -9
  95. package/src/tui/input.ts +8 -2
  96. package/src/tui/select.ts +24 -6
  97. package/src/tui/settings.ts +4 -0
  98. package/src/tui/system-theme.ts +700 -0
  99. package/src/tui/transcript.ts +63 -11
  100. package/src/tui/tree.ts +161 -95
  101. package/src/vendor/pi-rich/LICENSE.upstream +21 -0
  102. package/src/vendor/pi-rich/UPSTREAM.json +17 -0
  103. package/src/vendor/pi-rich/clipboard-command.ts +44 -0
  104. package/src/vendor/pi-rich/exif-orientation.ts +182 -0
  105. package/src/vendor/pi-rich/html.ts +51 -0
  106. package/src/vendor/pi-rich/image-convert.ts +49 -0
  107. package/src/vendor/pi-rich/image-resize-core.ts +164 -0
  108. package/src/vendor/pi-rich/image-resize-worker.ts +42 -0
  109. package/src/vendor/pi-rich/image-resize.ts +123 -0
  110. package/src/vendor/pi-rich/mime.ts +116 -0
  111. package/src/vendor/pi-rich/photon.ts +139 -0
  112. package/src/vendor/pi-rich/syntax-highlight.ts +214 -0
  113. package/src/vendor/pi-tui/UPSTREAM.json +50 -0
  114. package/src/vendor/pi-tui/VENDOR.md +7 -5
  115. package/src/vendor/pi-tui/autocomplete.ts +35 -10
  116. package/src/vendor/pi-tui/colors.ts +367 -0
  117. package/src/vendor/pi-tui/components/box.ts +8 -9
  118. package/src/vendor/pi-tui/components/editor.ts +4 -2
  119. package/src/vendor/pi-tui/components/markdown.ts +8 -2
  120. package/src/vendor/pi-tui/index.ts +31 -1
  121. package/src/vendor/pi-tui/native/UPSTREAM.json +23 -0
  122. package/src/vendor/pi-tui/native/clipboard.h +208 -0
  123. package/src/vendor/pi-tui/native/darwin/README.md +22 -0
  124. package/src/vendor/pi-tui/native/darwin/build.sh +69 -0
  125. package/src/vendor/pi-tui/native/darwin/prebuilds/darwin-arm64/darwin-platform.node +0 -0
  126. package/src/vendor/pi-tui/native/darwin/prebuilds/darwin-x64/darwin-platform.node +0 -0
  127. package/src/vendor/pi-tui/native/darwin/src/darwin-platform.m +132 -0
  128. package/src/vendor/pi-tui/native/linux/README.md +23 -0
  129. package/src/vendor/pi-tui/native/linux/build.sh +65 -0
  130. package/src/vendor/pi-tui/native/linux/prebuilds/linux-arm64/linux-platform-x11.node +0 -0
  131. package/src/vendor/pi-tui/native/linux/prebuilds/linux-x64/linux-platform-x11.node +0 -0
  132. package/src/vendor/pi-tui/native/linux/src/clipboard-worker.h +70 -0
  133. package/src/vendor/pi-tui/native/linux/src/linux-platform-x11.c +274 -0
  134. package/src/vendor/pi-tui/native/napi.h +91 -0
  135. package/src/vendor/pi-tui/native/win32/README.md +31 -0
  136. package/src/vendor/pi-tui/native/win32/build.mjs +232 -0
  137. package/src/vendor/pi-tui/native/win32/prebuilds/win32-arm64/win32-platform.node +0 -0
  138. package/src/vendor/pi-tui/native/win32/prebuilds/win32-x64/win32-platform.node +0 -0
  139. package/src/vendor/pi-tui/native/win32/src/win32-platform.c +241 -0
  140. package/src/vendor/pi-tui/native-platform.ts +2 -0
  141. package/src/vendor/pi-tui/oklab.ts +233 -0
  142. package/src/vendor/pi-tui/terminal-colors.ts +26 -8
  143. package/src/vendor/pi-tui/terminal-image.ts +43 -8
  144. package/src/vendor/pi-tui/terminal.ts +16 -9
  145. package/src/vendor/pi-tui/tui-alt-screen.ts +26 -18
  146. package/src/vendor/pi-tui/tui.ts +112 -75
  147. package/src/vendor/pi-tui/utils.ts +89 -49
  148. package/src/vendor/pi-tui/wheel-scroll.ts +82 -0
package/src/tui/editor.ts CHANGED
@@ -19,6 +19,7 @@
19
19
  import { Editor as PiEditor, type TUI } from "../vendor/pi-tui/index.ts";
20
20
  import { getKeybindings, setKeybindings, type KeybindingsManager } from "../vendor/pi-tui/keybindings.ts";
21
21
  import type { AutocompleteProvider } from "../vendor/pi-tui/autocomplete.ts";
22
+ import type { EditorComponent } from "../vendor/pi-tui/editor-component.ts";
22
23
  import { encodeKey } from "./input.ts";
23
24
  import { IDENTITY, PLAIN_ATTRS, paint, type Style } from "./style.ts";
24
25
  import { completePath, pathTokenAtCursor, type PathToken } from "./complete.ts";
@@ -763,6 +764,10 @@ function maskSecret(text: string): string {
763
764
  return out;
764
765
  }
765
766
 
767
+ class OwnedPiEditor extends PiEditor {
768
+ rebind(tui: TUI): void { this.tui = tui; }
769
+ }
770
+
766
771
  export class Editor implements Component {
767
772
  private secret: boolean;
768
773
  private submitted: string | null = null;
@@ -778,8 +783,13 @@ export class Editor implements Component {
778
783
  * turn will spend (pi's behaviour). Identity until the Tui sets it.
779
784
  */
780
785
  borderColor: (s: string) => string = (s) => s;
781
- readonly host: TUI;
782
- private pi: PiEditor;
786
+ host: TUI;
787
+ private pi: OwnedPiEditor;
788
+ private custom: (EditorComponent & { dispose?(): void; focused?: boolean }) | undefined;
789
+ private lastCustomText = "";
790
+ onComponentError: ((error: unknown) => void) | undefined;
791
+ private padding = 0;
792
+ private autocompleteRows = 5;
783
793
 
784
794
  constructor(opts: CreateEditorOptions = {}) {
785
795
  this.secret = opts.secret ?? false;
@@ -790,9 +800,9 @@ export class Editor implements Component {
790
800
  this.pi.setText(opts.text ?? "");
791
801
  }
792
802
 
793
- private createPi(): PiEditor {
803
+ private createPi(): OwnedPiEditor {
794
804
  const theme = identityEditorTheme();
795
- const pi = new PiEditor(this.host, {
805
+ const pi = new OwnedPiEditor(this.host, {
796
806
  ...theme,
797
807
  borderColor: (s) => this.borderColor(s),
798
808
  selectList: {
@@ -803,6 +813,8 @@ export class Editor implements Component {
803
813
  noMatch: (s) => paint(s, { ...PLAIN_ATTRS, dim: true }, this.style),
804
814
  },
805
815
  });
816
+ pi.setPaddingX(this.padding);
817
+ pi.setAutocompleteMaxVisible(this.autocompleteRows);
806
818
  if (!this.secret) {
807
819
  for (const text of [...this.history].reverse()) pi.addToHistory(text);
808
820
  if (this.provider) pi.setAutocompleteProvider(this.provider);
@@ -818,8 +830,40 @@ export class Editor implements Component {
818
830
  return pi;
819
831
  }
820
832
 
821
- get focused(): boolean { return this.pi.focused; }
822
- set focused(value: boolean) { this.pi.focused = value; }
833
+ /** Application key ownership stays outside the replaceable text component. */
834
+ setComponent(component: (EditorComponent & { dispose?(): void; focused?: boolean }) | undefined): void {
835
+ const text = this.state.text, focused = this.focused;
836
+ try { this.custom?.dispose?.(); } catch (error) { this.onComponentError?.(error); }
837
+ this.custom = component;
838
+ if (component) {
839
+ component.onSubmit = text => {
840
+ this.submitted = text;
841
+ if (text.trim()) { component.addToHistory?.(text); if (this.history[0] !== text) this.history.unshift(text); this.history = this.history.slice(0, this.historyLimit); }
842
+ };
843
+ component.setPaddingX?.(this.padding); component.setAutocompleteMaxVisible?.(this.autocompleteRows);
844
+ if (this.provider) component.setAutocompleteProvider?.(this.provider);
845
+ for (const entry of [...this.history].reverse()) component.addToHistory?.(entry);
846
+ }
847
+ this.applyExternal(text); this.focused = focused;
848
+ }
849
+ private get active(): EditorComponent & { focused?: boolean } { return !this.secret && this.custom ? this.custom : this.pi; }
850
+ private using<T>(operation: (editor: EditorComponent & { focused?: boolean }) => T): T {
851
+ const active = this.active;
852
+ try { return operation(active); } catch (error) {
853
+ if (active === this.pi) throw error;
854
+ const broken = this.custom; this.custom = undefined; this.pi.setText(this.lastCustomText); this.pi.focused = true;
855
+ try { broken?.dispose?.(); } catch { /* Report the original component failure. */ }
856
+ this.onComponentError?.(error); return operation(this.pi);
857
+ }
858
+ }
859
+ get focused(): boolean { return this.using(editor => editor.focused ?? false); }
860
+ rebind(tui: TUI): void { this.host = tui; this.pi.rebind(tui); }
861
+ configure(padding: number, rows: number): void {
862
+ this.padding = padding; this.autocompleteRows = rows;
863
+ this.pi.setPaddingX(padding); this.pi.setAutocompleteMaxVisible(rows);
864
+ this.custom?.setPaddingX?.(padding); this.custom?.setAutocompleteMaxVisible?.(rows);
865
+ }
866
+ set focused(value: boolean) { this.using(editor => { editor.focused = value; }); }
823
867
  setStyle(style: Style): void { this.style = style; }
824
868
 
825
869
  handleInput(data: string): void {
@@ -831,26 +875,27 @@ export class Editor implements Component {
831
875
  this.submitted = null;
832
876
  const previous = getKeybindings();
833
877
  if (this.bindings) setKeybindings(this.bindings);
834
- try { this.pi.handleInput(data); }
878
+ try { this.using(editor => editor.handleInput(data)); }
835
879
  finally { setKeybindings(previous); }
836
880
  return this.state;
837
881
  }
838
882
 
839
- get autocompleteVisible(): boolean { return this.pi.isShowingAutocomplete(); }
883
+ get autocompleteVisible(): boolean { return this.active === this.pi && this.pi.isShowingAutocomplete(); }
840
884
 
841
885
  setAutocompleteProvider(provider: AutocompleteProvider): void {
842
886
  this.provider = provider;
843
- if (!this.secret) this.pi.setAutocompleteProvider(provider);
887
+ if (!this.secret) this.using(editor => editor.setAutocompleteProvider?.(provider));
844
888
  }
845
889
 
846
890
  invalidate(): void {
847
- this.pi.invalidate();
891
+ this.using(editor => editor.invalidate());
848
892
  }
849
893
 
850
894
  get state(): EditorState {
851
- const text = this.pi.getExpandedText();
895
+ const text = this.using(editor => editor.getExpandedText?.() ?? editor.getText());
896
+ if (!this.secret) this.lastCustomText = text;
852
897
  const pos = this.pi.getCursor();
853
- const cursor = this.pi.getLines().slice(0, pos.line).reduce((n, line) => n + line.length + 1, 0) + pos.col;
898
+ const cursor = this.active === this.pi ? this.pi.getLines().slice(0, pos.line).reduce((n, line) => n + line.length + 1, 0) + pos.col : text.length;
854
899
  return { ...createEditor(), text, cursor, submitted: this.submitted, secret: this.secret,
855
900
  history: this.history, historyLimit: this.historyLimit };
856
901
  }
@@ -860,7 +905,8 @@ export class Editor implements Component {
860
905
  }
861
906
 
862
907
  applyExternal(text: string): EditorState {
863
- this.pi.setText(text);
908
+ if (!this.secret) this.lastCustomText = text;
909
+ this.using(editor => editor.setText(text));
864
910
  this.submitted = null;
865
911
  return this.state;
866
912
  }
@@ -868,12 +914,13 @@ export class Editor implements Component {
868
914
  setSecret(secret: boolean): EditorState {
869
915
  if (this.secret === secret) return this.state;
870
916
  const focused = this.focused;
871
- const text = this.secret ? "" : this.pi.getExpandedText();
917
+ const text = this.secret ? "" : this.state.text;
872
918
  this.secret = secret;
873
919
  // Discard secret undo/history at the authentication boundary.
874
920
  this.pi = this.createPi();
875
921
  this.pi.focused = focused;
876
922
  this.pi.setText(text);
923
+ if (!secret) this.using(editor => editor.setText(text));
877
924
  return this.state;
878
925
  }
879
926
 
@@ -888,6 +935,6 @@ export class Editor implements Component {
888
935
  for (const _ of this.pi.getText().slice(this.state.cursor)) masked.handleInput("\x1b[D");
889
936
  return masked.render(width);
890
937
  }
891
- return this.pi.render(width);
938
+ return this.using(editor => { editor.borderColor = this.borderColor; return editor.render(width); });
892
939
  }
893
940
  }
package/src/tui/host.ts CHANGED
@@ -21,11 +21,28 @@ import {
21
21
  type Terminal,
22
22
  type TUI,
23
23
  TuiMainScreen,
24
+ TuiAltScreen,
24
25
  stripTerminalSequences,
25
26
  } from "../vendor/pi-tui/index.ts";
26
27
 
27
28
  export { Container, Markdown, PiText };
28
29
  export type { TuiMainScreen };
30
+ /** A single document inset keeps committed and streaming messages aligned. */
31
+ export class PaddedDocument extends Container {
32
+ padding = 1;
33
+ override render(width: number): string[] {
34
+ const padding = Math.min(this.padding, Math.max(0, Math.floor((width - 1) / 2)));
35
+ return super.render(Math.max(1, width - padding * 2)).map(line => line.startsWith("\x1b]133;A\x07")
36
+ ? "\x1b]133;A\x07" + " ".repeat(padding) + line.slice(8) : " ".repeat(padding) + line);
37
+ }
38
+ }
39
+ export type InteractiveScreen = TuiMainScreen | TuiAltScreen;
40
+ export interface ScreenOptions {
41
+ tuiMode?: "regular" | "fullscreen";
42
+ fullscreenCopyOnSelect?: boolean;
43
+ fullscreenWheelScrollLines?: "auto" | number;
44
+ copySelection?: (text: string) => Promise<boolean | string>;
45
+ }
29
46
 
30
47
  /**
31
48
  * Wraps the injectible {columns, rows, write} terminal tests already use.
@@ -76,8 +93,19 @@ export class AdapterTerminal implements Terminal {
76
93
  }
77
94
 
78
95
  /** TuiMainScreen without ProcessTerminal — tests inject a FakeTerminal. */
79
- export function createInteractiveTui(term?: SimpleTerminal, showHardwareCursor = true): TuiMainScreen {
80
- return new TuiMainScreen(term ? new AdapterTerminal(term) : new ProcessTerminal(), showHardwareCursor);
96
+ export function createInteractiveTui(term?: SimpleTerminal, showHardwareCursor = true, options: ScreenOptions = {}): InteractiveScreen {
97
+ return screenFor(term ? new AdapterTerminal(term) : new ProcessTerminal(), showHardwareCursor, options);
98
+ }
99
+
100
+ export function replaceInteractiveTui(previous: InteractiveScreen, options: ScreenOptions): InteractiveScreen {
101
+ return screenFor(previous.terminal, previous.getShowHardwareCursor(), options);
102
+ }
103
+
104
+ function screenFor(terminal: Terminal, cursor: boolean, options: ScreenOptions): InteractiveScreen {
105
+ return options.tuiMode === "fullscreen" ? new TuiAltScreen(terminal, cursor, undefined, {
106
+ wheelScrollLines: options.fullscreenWheelScrollLines ?? "auto", copyOnSelect: options.fullscreenCopyOnSelect ?? true,
107
+ ...(options.copySelection ? { copySelection: options.copySelection } : {}), scrollToEndIndicator: () => "↓ Latest",
108
+ }) : new TuiMainScreen(terminal, cursor);
81
109
  }
82
110
 
83
111
  /**
@@ -145,8 +173,13 @@ export function renderMarkdownLines(text: string, width: number, style: Style):
145
173
  }
146
174
 
147
175
  /** Activity-area assistant text: pi's Markdown, themed from our Style. */
148
- export function assistantMarkdown(text: string, style: Style): Component {
149
- return new MarkdownBlock(text, style);
176
+ export function assistantMarkdown(text: string, style: Style, features?: MarkdownFeatures): Component {
177
+ return new MarkdownBlock(text, style, features);
178
+ }
179
+
180
+ export interface MarkdownFeatures {
181
+ highlight: (code: string, language: string | undefined, style: Style) => string[];
182
+ transform: (text: string, width: number, streaming: boolean, style: Style, messageType?: "user" | "assistant" | "assistant-thinking") => string;
150
183
  }
151
184
 
152
185
  /** Keep Pi's parse/layout cache alive across keystrokes and streaming deltas. */
@@ -154,19 +187,27 @@ export class MarkdownBlock implements Component {
154
187
  private readonly markdown: Markdown;
155
188
  private readonly style: Style;
156
189
  private text: string;
157
- constructor(text: string, style: Style) {
190
+ private readonly features: MarkdownFeatures | undefined;
191
+ private readonly streaming: boolean;
192
+ private renderedText: string | undefined;
193
+ constructor(text: string, style: Style, features?: MarkdownFeatures, streaming = false) {
158
194
  this.text = text;
159
195
  this.style = style;
160
- this.markdown = new Markdown(text, 0, 0, markdownThemeFromStyle(style), defaultTextStyleFrom(style));
196
+ this.features = features; this.streaming = streaming;
197
+ this.markdown = new Markdown(text, 0, 0, { ...markdownThemeFromStyle(style),
198
+ ...(features && style !== IDENTITY ? { highlightCode: (code: string, lang?: string) => features.highlight(code, lang, style) } : {}),
199
+ }, defaultTextStyleFrom(style));
161
200
  }
162
201
  setText(text: string): void {
163
202
  if (text === this.text) return;
164
203
  this.text = text;
165
- this.markdown.setText(text);
204
+ this.renderedText = undefined;
166
205
  }
167
206
  invalidate(): void { this.markdown.invalidate(); }
168
207
  render(width: number): string[] {
169
208
  if (width <= 0) return [""];
209
+ const transformed = this.features?.transform(this.text, width, this.streaming, this.style) ?? this.text;
210
+ if (this.renderedText !== transformed) { this.markdown.setText(transformed); this.renderedText = transformed; }
170
211
  const lines = this.markdown.render(Math.max(1, width)).map((line) => line.trimEnd());
171
212
  return this.style === IDENTITY ? lines.map(stripTerminalSequences) : lines;
172
213
  }
@@ -205,8 +246,8 @@ export function markdownThemeFromStyle(style: Style): MarkdownTheme {
205
246
  listBullet: wrapAttr(style, { ...PLAIN_ATTRS, dim: true }),
206
247
  bold: wrapAttr(style, { ...PLAIN_ATTRS, bold: true }),
207
248
  italic: wrapAttr(style, { ...PLAIN_ATTRS, italic: true }),
208
- strikethrough: (t) => t,
209
- underline: (t) => t,
249
+ strikethrough: (t) => `\x1b[9m${t}\x1b[29m`,
250
+ underline: (t) => `\x1b[4m${t}\x1b[24m`,
210
251
  };
211
252
  }
212
253
 
package/src/tui/input.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /** Pi owns decoding. Encoding exists only for programmatic actions and legacy tests. */
2
- import { KeybindingsManager, TUI_KEYBINDINGS, type KeybindingsConfig } from "../vendor/pi-tui/keybindings.ts";
2
+ import { KeybindingsManager, TUI_KEYBINDINGS, setKeybindings, type KeybindingsConfig } from "../vendor/pi-tui/keybindings.ts";
3
3
  import { matchesKey, parseKey, type KeyId } from "../vendor/pi-tui/keys.ts";
4
4
  import { formatTuiKey, type Key } from "./keys.ts";
5
5
 
@@ -62,8 +62,14 @@ const INPUT_ACTIONS: Record<string, string> = {
62
62
  export function editorBindings(byAction: Readonly<Record<string, readonly string[]>>): KeybindingsManager {
63
63
  const config: KeybindingsConfig = {};
64
64
  for (const [action, chords] of Object.entries(byAction)) {
65
- const id = INPUT_ACTIONS[action] ?? `tui.${action}`;
65
+ const id = action.startsWith("tui.") ? action : INPUT_ACTIONS[action] ?? `tui.${action}`;
66
66
  if (id in TUI_KEYBINDINGS) config[id] = [...chords] as KeyId[];
67
67
  }
68
68
  return new KeybindingsManager(TUI_KEYBINDINGS, config);
69
69
  }
70
+
71
+ export function installBindings(byAction: Readonly<Record<string, readonly string[]>>): KeybindingsManager {
72
+ const manager = editorBindings(byAction);
73
+ setKeybindings(manager);
74
+ return manager;
75
+ }
package/src/tui/select.ts CHANGED
@@ -15,7 +15,8 @@ import { Input } from "../vendor/pi-tui/components/input.ts";
15
15
  import { SelectList as PiSelectList } from "../vendor/pi-tui/components/select-list.ts";
16
16
  import { fuzzyFilter as piFuzzyFilter } from "../vendor/pi-tui/fuzzy.ts";
17
17
  import { truncateToWidth } from "../vendor/pi-tui/utils.ts";
18
- import { encodeKey, keyMatches } from "./input.ts";
18
+ import { getKeybindings } from "../vendor/pi-tui/keybindings.ts";
19
+ import { encodeKey } from "./input.ts";
19
20
 
20
21
  /**
21
22
  * Default visible rows. Assumed, not measured: enough to scan a /model
@@ -37,6 +38,7 @@ export interface CreateSelectOptions {
37
38
  readonly selectedValue?: string;
38
39
  readonly search?: boolean;
39
40
  readonly hint?: string;
41
+ readonly filter?: (items: readonly SelectItem[], query: string) => readonly SelectItem[];
40
42
  }
41
43
 
42
44
  export interface SelectState {
@@ -167,7 +169,7 @@ export function renderSelect(state: SelectState, width: number, style?: Style):
167
169
  export class SelectList implements Component {
168
170
  private current: SelectState;
169
171
  private style: Style | undefined;
170
- private readonly items: readonly SelectItem[];
172
+ private items: readonly SelectItem[];
171
173
  private readonly options: CreateSelectOptions;
172
174
  private readonly search = new Input({ prompt: "Search: " });
173
175
  private list: PiSelectList;
@@ -192,6 +194,7 @@ export class SelectList implements Component {
192
194
  }
193
195
 
194
196
  get focused(): boolean { return this.search.focused; }
197
+ get query(): string { return this.search.getValue(); }
195
198
  set focused(value: boolean) { this.search.focused = value; }
196
199
  invalidate(): void { this.search.invalidate(); this.list.invalidate(); }
197
200
  handleInput(data: string): void { if (this.onInput) this.onInput(data); else this.acceptInput(data); }
@@ -204,14 +207,26 @@ export class SelectList implements Component {
204
207
  this.list.setSelectedIndex(selected);
205
208
  }
206
209
 
210
+ setItems(items: readonly SelectItem[]): void {
211
+ const selected = this.state.items[this.state.selected]?.value;
212
+ this.items = items;
213
+ const filtered = this.filter(items, this.search.getValue());
214
+ this.current = { ...this.current, items: filtered, selected: Math.max(0, filtered.findIndex(item => item.value === selected)) };
215
+ this.list = this.makeList();
216
+ this.list.setSelectedIndex(this.current.selected);
217
+ }
218
+
207
219
  acceptInput(data: string): SelectState {
208
220
  this.current = { ...this.current, confirmed: null, cancelled: false };
209
- if (["up", "down", "enter", "escape", "ctrl+c"].some((key) => keyMatches(data, key))) this.list.handleInput(data);
210
- else if (keyMatches(data, "pageUp") || keyMatches(data, "pageDown")) {
211
- this.list.setSelectedIndex(this.state.selected + (keyMatches(data, "pageUp") ? -1 : 1) * this.current.maxVisible);
221
+ const kb = getKeybindings();
222
+ if ((["tui.select.up", "tui.select.down", "tui.select.confirm", "tui.select.cancel"] as const).some(key => kb.matches(data, key))) this.list.handleInput(data);
223
+ else if (kb.matches(data, "tui.select.pageUp") || kb.matches(data, "tui.select.pageDown")) {
224
+ this.list.setSelectedIndex(this.state.selected + (kb.matches(data, "tui.select.pageUp") ? -1 : 1) * this.current.maxVisible);
212
225
  } else if (this.options.search !== false) {
226
+ const before = this.search.getValue();
213
227
  this.search.handleInput(data);
214
- const filtered = piFuzzyFilter([...this.items], this.search.getValue(), (item) => `${item.label} ${item.description ?? ""}`);
228
+ if (before === this.search.getValue()) return this.state;
229
+ const filtered = this.filter(this.items, this.search.getValue());
215
230
  this.current = { ...this.current, items: filtered, selected: 0 };
216
231
  this.list = this.makeList();
217
232
  }
@@ -228,6 +243,9 @@ export class SelectList implements Component {
228
243
  list.onCancel = () => { this.current = { ...this.current, cancelled: true }; };
229
244
  return list;
230
245
  }
246
+ private filter(items: readonly SelectItem[], query: string): readonly SelectItem[] {
247
+ return this.options.filter?.(items, query) ?? piFuzzyFilter([...items], query, item => `${item.label} ${item.description ?? ""}`);
248
+ }
231
249
 
232
250
  render(width: number): string[] {
233
251
  if (width <= 0) return [""];
@@ -31,6 +31,10 @@ export class Settings {
31
31
  return this.changes;
32
32
  }
33
33
  updateValue(id: string, value: string): void { this.list.updateValue(id, value); }
34
+ updateValues(id: string, values: string[]): void {
35
+ const item = this.items.find(item => item.id === id);
36
+ if (item) item.values = values;
37
+ }
34
38
  invalidate(): void { this.list.invalidate(); }
35
39
  render(width: number): string[] {
36
40
  if (width <= 0) return [""];