@aitherium/shell-cli 1.11.1 → 1.12.1

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.
@@ -33,6 +33,11 @@ export interface TuiSurface {
33
33
  clearPanes(): void;
34
34
  /** Show/hide the TRACE pane (OUTPUT widens to fill). */
35
35
  toggleTrace(): void;
36
+ /** Stash the suggested follow-ups from the last turn so a bare digit in the
37
+ * input box can expand to "send the Nth follow-up". Cleared when consumed. */
38
+ setPendingFollowups(items: string[]): void;
39
+ /** The currently-stashed follow-ups (empty if none / already consumed). */
40
+ getPendingFollowups(): string[];
36
41
  /** Coalesced repaint. */
37
42
  render(): void;
38
43
  /** Focus the input box for typing. */
@@ -43,6 +48,13 @@ export interface TuiSurface {
43
48
  pickerOpen(): boolean;
44
49
  /** Show a filterable command picker; resolves with the chosen value or null. */
45
50
  showPicker(title: string, items: PickerItem[], initialFilter?: string): Promise<string | null>;
51
+ /** Show a full-screen scrollable document viewer over pre-rendered lines
52
+ * (markdown/code/text). Resolves when the user closes it (q/Esc). */
53
+ showViewer(title: string, lines: string[]): Promise<void>;
54
+ /** Show a full-screen multi-line text editor seeded with `initialText`.
55
+ * `onSave(text)` persists and returns an error string (or null on success).
56
+ * Resolves when the user closes the editor (Esc/Ctrl+Q). */
57
+ showEditor(title: string, initialText: string, onSave: (text: string) => Promise<string | null>): Promise<void>;
46
58
  /**
47
59
  * Suspend the blessed screen, run `fn` on the REAL terminal (so command
48
60
  * handlers' console.log / ora spinners / @inquirer prompts all work and don't
@@ -155,6 +155,9 @@ export function createTuiScreen(opts) {
155
155
  scheduleRender();
156
156
  }
157
157
  function markCheckpoint() { return outputBuf.length; }
158
+ let pendingFollowups = [];
159
+ function setPendingFollowups(items) { pendingFollowups = Array.isArray(items) ? items.slice(0, 9) : []; }
160
+ function getPendingFollowups() { return pendingFollowups; }
158
161
  function replaceOutputFrom(offset, text) {
159
162
  if (offset < 0 || offset > outputBuf.length)
160
163
  return;
@@ -199,6 +202,18 @@ export function createTuiScreen(opts) {
199
202
  for (const l of t.lines)
200
203
  out.push(l);
201
204
  });
205
+ // Belt-and-suspenders against blessed "character bleed": when the trace
206
+ // SHRINKS (a long turn collapses into a short new one) blessed's wide-char
207
+ // (emoji) miscount in wrapped lines leaves stale glyphs in the now-uncovered
208
+ // rows. clearRegion alone proved unreliable for this, so when the content is
209
+ // shorter than the pane, pad with blank lines so setContent paints over every
210
+ // visible row. Skip when the user has scrolled up (don't perturb their view)
211
+ // or when content already fills/overflows the pane (no uncovered rows).
212
+ if (!userPinnedTrace) {
213
+ const innerH = (typeof trace.height === 'number' ? trace.height : 0) - 2;
214
+ while (innerH > out.length)
215
+ out.push('');
216
+ }
202
217
  const base = trace.childBase || 0;
203
218
  try {
204
219
  trace.setContent(out.join('\n'));
@@ -726,11 +741,321 @@ export function createTuiScreen(opts) {
726
741
  screen.render();
727
742
  });
728
743
  }
744
+ // ── Document viewer overlay (full-screen scrollable box) ──────
745
+ // Reuses the _pickerOpen guard so the main keypress handler stands down while
746
+ // the viewer owns the keyboard. Content is pre-rendered (markdown/code/text +
747
+ // chalk ANSI); blessed parses the SGR colour codes itself. Images do NOT come
748
+ // through here — they render via runDetached so 24-bit colour isn't downsampled.
749
+ function showViewer(title, lines) {
750
+ return new Promise((resolve) => {
751
+ _pickerOpen = true;
752
+ const box = blessed.box({
753
+ parent: screen, label: ` ${title} — ↑↓/PgUp/PgDn scroll · g/G top/bottom · q/Esc close `,
754
+ border: 'line', top: 0, left: 0, width: '100%', height: '100%',
755
+ tags: false, scrollable: true, alwaysScroll: true, keys: true, mouse: true,
756
+ scrollbar: { ch: ' ', inverse: true }, wrap: true,
757
+ style: { border: { fg: 'cyan' }, label: { fg: 'cyan' } },
758
+ });
759
+ box.setContent(lines.join('\n'));
760
+ function close() {
761
+ _pickerOpen = false;
762
+ try {
763
+ box.destroy();
764
+ }
765
+ catch { /* */ }
766
+ screen.render();
767
+ focusInput();
768
+ resolve();
769
+ }
770
+ const vpage = () => Math.max(1, box.height - 3);
771
+ box.key(['q', 'escape', 'C-c'], () => close());
772
+ box.key(['up', 'k'], () => { box.scroll(-1); screen.render(); });
773
+ box.key(['down', 'j'], () => { box.scroll(1); screen.render(); });
774
+ box.key(['pageup'], () => { box.scroll(-vpage()); screen.render(); });
775
+ box.key(['pagedown', 'space'], () => { box.scroll(vpage()); screen.render(); });
776
+ box.key(['g', 'home'], () => { box.scrollTo(0); screen.render(); });
777
+ box.key(['G', 'end'], () => { box.setScrollPerc(100); screen.render(); });
778
+ box.on('wheelup', () => { box.scroll(-3); screen.render(); });
779
+ box.on('wheeldown', () => { box.scroll(3); screen.render(); });
780
+ box.focus();
781
+ screen.render();
782
+ });
783
+ }
784
+ // ── Document editor overlay (notepad/vim-lite, manual keypresses) ─────
785
+ // A real multi-line editor. We drive it from the PROGRAM keypress stream (same
786
+ // reason as the main input box: blessed's textarea double-delivers keys on this
787
+ // terminal). Lines are stored as code-point arrays so emoji/CJK caret maths is
788
+ // correct. While open, _pickerOpen suppresses the main onKey handler and a
789
+ // dedicated keypress listener owns the keyboard until the user quits.
790
+ function showEditor(title, initialText, onSave) {
791
+ return new Promise((resolve) => {
792
+ _pickerOpen = true;
793
+ const lines = (initialText.length ? initialText.replace(/\r\n/g, '\n').split('\n') : [''])
794
+ .map((l) => Array.from(l));
795
+ let cr = 0; // cursor row
796
+ let cc = 0; // cursor col (code-point index)
797
+ let top = 0; // first visible row (vertical scroll)
798
+ let dirty = false;
799
+ let msg = ''; // transient status message
800
+ let confirmingQuit = false;
801
+ const box = blessed.box({
802
+ parent: screen, border: 'line', top: 0, left: 0, width: '100%', height: '100%',
803
+ tags: false, style: { border: { fg: 'yellow' }, label: { fg: 'yellow' } },
804
+ });
805
+ const innerH = () => Math.max(1, box.height - 2);
806
+ const innerW = () => Math.max(20, box.width - 2);
807
+ const gutterW = () => String(lines.length).length;
808
+ function setLabel() {
809
+ const flag = dirty ? chalk.red(' ●') : '';
810
+ const pos = `${cr + 1}:${cc + 1}`;
811
+ const hint = confirmingQuit
812
+ ? chalk.red('unsaved — ^Q again to discard, ^S to save')
813
+ : chalk.dim('^S save · ^Q/Esc quit · arrows move · Enter newline');
814
+ const note = msg ? ' ' + chalk.green(msg) : '';
815
+ try {
816
+ box.setLabel(` ${title}${flag} ${chalk.dim(pos)} ${hint}${note} `);
817
+ }
818
+ catch { /* */ }
819
+ }
820
+ function clampView() {
821
+ if (cr < top)
822
+ top = cr;
823
+ if (cr >= top + innerH())
824
+ top = cr - innerH() + 1;
825
+ if (top < 0)
826
+ top = 0;
827
+ }
828
+ function render() {
829
+ clampView();
830
+ const g = gutterW();
831
+ const avail = innerW() - (g + 3); // gutter "NNN │ "
832
+ const out = [];
833
+ for (let r = top; r < Math.min(lines.length, top + innerH()); r++) {
834
+ const isCur = r === cr;
835
+ const cps = lines[r];
836
+ // Horizontal window so the caret stays visible on long lines.
837
+ let start = 0;
838
+ if (isCur && cc > avail - 1)
839
+ start = cc - (avail - 1);
840
+ const slice = cps.slice(start, start + avail);
841
+ const gutter = chalk.dim(String(r + 1).padStart(g, ' ') + ' │ ');
842
+ if (isCur) {
843
+ const rel = cc - start;
844
+ const before = slice.slice(0, rel).join('');
845
+ const atCh = slice[rel] ?? ' ';
846
+ const after = slice.slice(rel + 1).join('');
847
+ const lead = start > 0 ? chalk.dim('‹') : '';
848
+ out.push(gutter + lead + before + chalk.inverse(atCh) + after);
849
+ }
850
+ else {
851
+ out.push(gutter + slice.join(''));
852
+ }
853
+ }
854
+ try {
855
+ box.setContent(out.join('\n'));
856
+ }
857
+ catch { /* */ }
858
+ setLabel();
859
+ scheduleRender();
860
+ }
861
+ function curLine() { return lines[cr]; }
862
+ async function doSave() {
863
+ const text = lines.map((l) => l.join('')).join('\n');
864
+ const err = await onSave(text);
865
+ if (err) {
866
+ msg = '';
867
+ setLabel();
868
+ try {
869
+ box.setLabel(` ${title} ${chalk.red('save failed: ' + err)} `);
870
+ }
871
+ catch { /* */ }
872
+ scheduleRender();
873
+ }
874
+ else {
875
+ dirty = false;
876
+ confirmingQuit = false;
877
+ msg = 'saved';
878
+ render();
879
+ setTimeout(() => { msg = ''; setLabel(); scheduleRender(); }, 1500);
880
+ }
881
+ }
882
+ function close() {
883
+ screen.program.removeListener('keypress', editorKey);
884
+ _pickerOpen = false;
885
+ try {
886
+ box.destroy();
887
+ }
888
+ catch { /* */ }
889
+ screen.render();
890
+ focusInput();
891
+ resolve();
892
+ }
893
+ function editorKey(ch, key) {
894
+ const name = (key && key.name) || '';
895
+ const ctrl = !!(key && key.ctrl);
896
+ // Save / quit.
897
+ if (ctrl && name === 's') {
898
+ void doSave();
899
+ return;
900
+ }
901
+ if ((ctrl && name === 'q') || name === 'escape') {
902
+ if (dirty && !confirmingQuit) {
903
+ confirmingQuit = true;
904
+ setLabel();
905
+ scheduleRender();
906
+ return;
907
+ }
908
+ close();
909
+ return;
910
+ }
911
+ confirmingQuit = false;
912
+ // Navigation.
913
+ if (name === 'up') {
914
+ if (cr > 0) {
915
+ cr--;
916
+ cc = Math.min(cc, curLine().length);
917
+ }
918
+ render();
919
+ return;
920
+ }
921
+ if (name === 'down') {
922
+ if (cr < lines.length - 1) {
923
+ cr++;
924
+ cc = Math.min(cc, curLine().length);
925
+ }
926
+ render();
927
+ return;
928
+ }
929
+ if (name === 'left') {
930
+ if (cc > 0)
931
+ cc--;
932
+ else if (cr > 0) {
933
+ cr--;
934
+ cc = curLine().length;
935
+ }
936
+ render();
937
+ return;
938
+ }
939
+ if (name === 'right') {
940
+ if (cc < curLine().length)
941
+ cc++;
942
+ else if (cr < lines.length - 1) {
943
+ cr++;
944
+ cc = 0;
945
+ }
946
+ render();
947
+ return;
948
+ }
949
+ if (name === 'home') {
950
+ cc = 0;
951
+ render();
952
+ return;
953
+ }
954
+ if (name === 'end') {
955
+ cc = curLine().length;
956
+ render();
957
+ return;
958
+ }
959
+ if (name === 'pageup') {
960
+ cr = Math.max(0, cr - innerH());
961
+ cc = Math.min(cc, curLine().length);
962
+ render();
963
+ return;
964
+ }
965
+ if (name === 'pagedown') {
966
+ cr = Math.min(lines.length - 1, cr + innerH());
967
+ cc = Math.min(cc, curLine().length);
968
+ render();
969
+ return;
970
+ }
971
+ // Editing.
972
+ if (name === 'enter' || name === 'return' || name === 'linefeed') {
973
+ const cur = curLine();
974
+ const tail = cur.slice(cc);
975
+ lines[cr] = cur.slice(0, cc);
976
+ lines.splice(cr + 1, 0, tail);
977
+ cr++;
978
+ cc = 0;
979
+ dirty = true;
980
+ render();
981
+ return;
982
+ }
983
+ if (name === 'backspace') {
984
+ if (cc > 0) {
985
+ curLine().splice(cc - 1, 1);
986
+ cc--;
987
+ dirty = true;
988
+ }
989
+ else if (cr > 0) {
990
+ const cur = lines.splice(cr, 1)[0];
991
+ cc = lines[cr - 1].length;
992
+ lines[cr - 1] = lines[cr - 1].concat(cur);
993
+ cr--;
994
+ dirty = true;
995
+ }
996
+ render();
997
+ return;
998
+ }
999
+ if (name === 'delete') {
1000
+ if (cc < curLine().length) {
1001
+ curLine().splice(cc, 1);
1002
+ dirty = true;
1003
+ }
1004
+ else if (cr < lines.length - 1) {
1005
+ const next = lines.splice(cr + 1, 1)[0];
1006
+ lines[cr] = curLine().concat(next);
1007
+ dirty = true;
1008
+ }
1009
+ render();
1010
+ return;
1011
+ }
1012
+ if (name === 'tab') {
1013
+ curLine().splice(cc, 0, ' ', ' ');
1014
+ cc += 2;
1015
+ dirty = true;
1016
+ render();
1017
+ return;
1018
+ }
1019
+ // Printable insert (paste may deliver several chars; respects newlines).
1020
+ if (ch && typeof ch === 'string' && ch.length >= 1 && ch >= ' ' && !(key && (key.ctrl || key.meta))) {
1021
+ for (const c of Array.from(ch)) {
1022
+ curLine().splice(cc, 0, c);
1023
+ cc++;
1024
+ }
1025
+ dirty = true;
1026
+ render();
1027
+ return;
1028
+ }
1029
+ // Multi-line paste arriving as a chunk with embedded newlines.
1030
+ if (ch && typeof ch === 'string' && ch.includes('\n')) {
1031
+ for (const c of Array.from(ch)) {
1032
+ if (c === '\n') {
1033
+ const tail = curLine().slice(cc);
1034
+ lines[cr] = curLine().slice(0, cc);
1035
+ lines.splice(cr + 1, 0, tail);
1036
+ cr++;
1037
+ cc = 0;
1038
+ }
1039
+ else if (c >= ' ') {
1040
+ curLine().splice(cc, 0, c);
1041
+ cc++;
1042
+ }
1043
+ }
1044
+ dirty = true;
1045
+ render();
1046
+ return;
1047
+ }
1048
+ }
1049
+ screen.program.on('keypress', editorKey);
1050
+ render();
1051
+ });
1052
+ }
729
1053
  renderInput();
730
1054
  screen.render();
731
1055
  return {
732
1056
  screen, input, appendOutput, outputLine, markCheckpoint, replaceOutputFrom, prependOutput,
733
1057
  traceLine, startTraceTurn, finishTraceTurn, setStatus, setTracePanel, setOutputLabel, clearPanes, toggleTrace, render, focusInput, destroy,
734
- pickerOpen, showPicker, runDetached,
1058
+ setPendingFollowups, getPendingFollowups,
1059
+ pickerOpen, showPicker, showViewer, showEditor, runDetached,
735
1060
  };
736
1061
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aitherium/shell-cli",
3
- "version": "1.11.1",
3
+ "version": "1.12.1",
4
4
  "description": "Interactive terminal for AitherOS and ADK agents",
5
5
  "license": "BUSL-1.1",
6
6
  "type": "module",