sapper-iq 1.3.2 → 1.4.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 (3) hide show
  1. package/package.json +1 -1
  2. package/sapper-ui.mjs +356 -7
  3. package/sapper.mjs +141 -3
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sapper-iq",
3
- "version": "1.3.2",
3
+ "version": "1.4.0",
4
4
  "description": "AI-powered development assistant that executes commands and builds projects",
5
5
  "main": "sapper.mjs",
6
6
  "bin": {
package/sapper-ui.mjs CHANGED
@@ -313,6 +313,12 @@ function buildHTML() {
313
313
  #indexPanel .chip .cp { max-width: 160px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
314
314
  #indexPanel .chip .cx { cursor: pointer; opacity: .55; padding: 0 3px; font-size: 12px; }
315
315
  #indexPanel .chip .cx:hover { opacity: 1; color: var(--red); }
316
+ #indexPanel .chip .cdeep { cursor: pointer; font-size: 9px; line-height: 1;
317
+ padding: 1px 5px; border-radius: 7px; border: 1px solid var(--border2);
318
+ background: rgba(255,255,255,.04); color: var(--muted); text-transform: uppercase;
319
+ letter-spacing: .04em; user-select: none; }
320
+ #indexPanel .chip .cdeep:hover { color: var(--fg); border-color: var(--accent); }
321
+ #indexPanel .chip .cdeep.on { color: #fff; background: var(--accent); border-color: var(--accent); }
316
322
  #indexPanel .empty { padding: 8px 10px; color: var(--dim); font-style: italic; }
317
323
  #indexPanel .icmt { display: block; margin: 4px 10px 8px; width: calc(100% - 20px);
318
324
  box-sizing: border-box; background: var(--panel); color: var(--fg);
@@ -389,6 +395,32 @@ function buildHTML() {
389
395
  .modal .actions button.primary:hover { filter: brightness(1.1); }
390
396
  .modal .actions button.danger { background: var(--red); color: #fff; border-color: var(--red); }
391
397
 
398
+ /* ─── Terminal Settings modal ─── */
399
+ #tsModal .modal { width: 520px; max-height: 92vh; overflow-y: auto; }
400
+ .ts-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 0 18px; }
401
+ .ts-full { grid-column: 1 / -1; }
402
+ .ts-row { display: flex; align-items: center; gap: 8px; margin: 2px 0 6px; }
403
+ .ts-row input[type=range] { flex: 1; accent-color: var(--accent); cursor: pointer; }
404
+ .ts-row .tsv { min-width: 34px; text-align: right; font-size: 11px;
405
+ color: var(--accent); font-family: ui-monospace,'SF Mono',monospace; }
406
+ .ts-row select, .ts-row input[type=color], .ts-row input[type=text] { flex: 1; }
407
+ .ts-row input[type=color] { height: 28px; padding: 2px; border-radius: 4px;
408
+ border: 1px solid var(--border2); background: var(--bg); cursor: pointer; }
409
+ .ts-swatches { display: flex; flex-wrap: wrap; gap: 6px; margin: 4px 0 10px; }
410
+ .ts-swatch { display: flex; flex-direction: column; align-items: center; gap: 3px;
411
+ cursor: pointer; user-select: none; }
412
+ .ts-swatch .tsb { width: 44px; height: 30px; border-radius: 6px; border: 2px solid transparent;
413
+ transition: border-color .15s, transform .1s; }
414
+ .ts-swatch:hover .tsb { transform: scale(1.06); }
415
+ .ts-swatch.active .tsb { border-color: var(--accent); }
416
+ .ts-swatch span { font-size: 9px; color: var(--muted); }
417
+ .ts-section { font-size: 10px; color: var(--dim); text-transform: uppercase;
418
+ letter-spacing: .6px; margin: 12px 0 6px; padding-bottom: 4px;
419
+ border-bottom: 1px solid var(--border); }
420
+ .ts-check { display: flex; align-items: center; gap: 6px; margin: 4px 0 8px;
421
+ font-size: 12px; color: var(--muted); cursor: pointer; }
422
+ .ts-check input { accent-color: var(--accent); cursor: pointer; }
423
+
392
424
  /* Config / Agents / Skills lists */
393
425
  .pane-section { padding: 10px 14px; }
394
426
  .pane-section h4 { font-size: 11px; color: var(--dim); text-transform: uppercase;
@@ -617,6 +649,7 @@ function buildHTML() {
617
649
  <button onclick="sendCmd('/model')">model</button>
618
650
  <button onclick="sendCmd('/clear')">clear</button>
619
651
  <button onclick="restartSapper()">restart</button>
652
+ <button title="Terminal appearance settings" onclick="openTermSettings()" style="font-size:13px;padding:3px 7px;">&#9881;</button>
620
653
  </div>
621
654
 
622
655
  <div id="body">
@@ -741,6 +774,114 @@ function buildHTML() {
741
774
  </div>
742
775
  <div id="toast"></div>
743
776
 
777
+ <!-- Terminal Settings Modal -->
778
+ <div id="tsModal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,.6);z-index:12000;display:none;align-items:center;justify-content:center;">
779
+ <div class="modal" style="width:520px;max-height:92vh;overflow-y:auto;">
780
+ <h3>&#9881; Terminal Appearance</h3>
781
+
782
+ <div class="ts-section">Themes</div>
783
+ <div class="ts-swatches" id="tsSwatches"></div>
784
+
785
+ <div class="ts-section">Colors</div>
786
+ <div class="ts-grid">
787
+ <div>
788
+ <label>Background</label>
789
+ <div class="ts-row">
790
+ <input type="color" id="tsBg" />
791
+ <input type="text" id="tsBgHex" placeholder="#0a0e14" style="width:90px;flex:none;font-family:ui-monospace,monospace;font-size:11px;" />
792
+ </div>
793
+ </div>
794
+ <div>
795
+ <label>Foreground</label>
796
+ <div class="ts-row">
797
+ <input type="color" id="tsFg" />
798
+ <input type="text" id="tsFgHex" placeholder="#e6edf3" style="width:90px;flex:none;font-family:ui-monospace,monospace;font-size:11px;" />
799
+ </div>
800
+ </div>
801
+ <div>
802
+ <label>Cursor</label>
803
+ <div class="ts-row">
804
+ <input type="color" id="tsCursor" />
805
+ <input type="text" id="tsCursorHex" placeholder="#58a6ff" style="width:90px;flex:none;font-family:ui-monospace,monospace;font-size:11px;" />
806
+ </div>
807
+ </div>
808
+ <div>
809
+ <label>Selection</label>
810
+ <div class="ts-row">
811
+ <input type="color" id="tsSel" />
812
+ <input type="text" id="tsSelHex" placeholder="#58a6ff" style="width:90px;flex:none;font-family:ui-monospace,monospace;font-size:11px;" />
813
+ </div>
814
+ </div>
815
+ </div>
816
+
817
+ <div class="ts-section">Font</div>
818
+ <div class="ts-grid">
819
+ <div class="ts-full">
820
+ <label>Font Family</label>
821
+ <div class="ts-row">
822
+ <select id="tsFont">
823
+ <option value='"SF Mono","Fira Code","JetBrains Mono",Menlo,ui-monospace,monospace'>SF Mono / Fira Code</option>
824
+ <option value='"JetBrains Mono","Fira Code",ui-monospace,monospace'>JetBrains Mono</option>
825
+ <option value='"Fira Code","Source Code Pro",ui-monospace,monospace'>Fira Code</option>
826
+ <option value='"Cascadia Code","Consolas",ui-monospace,monospace'>Cascadia Code</option>
827
+ <option value='"Monaco",Menlo,ui-monospace,monospace'>Monaco</option>
828
+ <option value='Menlo,ui-monospace,monospace'>Menlo</option>
829
+ <option value='"Courier New",monospace'>Courier New</option>
830
+ <option value='ui-monospace,monospace'>System Mono</option>
831
+ </select>
832
+ </div>
833
+ </div>
834
+ <div>
835
+ <label>Size &mdash; <span id="tsFontSzV">13</span>px</label>
836
+ <div class="ts-row">
837
+ <input type="range" id="tsFontSz" min="9" max="28" step="1" value="13" oninput="document.getElementById('tsFontSzV').textContent=this.value" />
838
+ </div>
839
+ </div>
840
+ <div>
841
+ <label>Line Height &mdash; <span id="tsLhV">1.25</span></label>
842
+ <div class="ts-row">
843
+ <input type="range" id="tsLh" min="1.0" max="2.0" step="0.05" value="1.25" oninput="document.getElementById('tsLhV').textContent=parseFloat(this.value).toFixed(2)" />
844
+ </div>
845
+ </div>
846
+ </div>
847
+
848
+ <div class="ts-section">Cursor &amp; Behavior</div>
849
+ <div class="ts-grid">
850
+ <div>
851
+ <label>Cursor Style</label>
852
+ <div class="ts-row">
853
+ <select id="tsCursorStyle">
854
+ <option value="bar">Bar |</option>
855
+ <option value="block">Block &#9646;</option>
856
+ <option value="underline">Underline _</option>
857
+ </select>
858
+ </div>
859
+ </div>
860
+ <div>
861
+ <label>Scrollback Lines</label>
862
+ <div class="ts-row">
863
+ <select id="tsScrollback">
864
+ <option value="1000">1,000</option>
865
+ <option value="5000">5,000</option>
866
+ <option value="10000" selected>10,000</option>
867
+ <option value="50000">50,000</option>
868
+ </select>
869
+ </div>
870
+ </div>
871
+ <div class="ts-full">
872
+ <label class="ts-check"><input type="checkbox" id="tsCursorBlink" checked /> Cursor blink</label>
873
+ <label class="ts-check"><input type="checkbox" id="tsLigatures" /> Font ligatures (requires compatible font)</label>
874
+ </div>
875
+ </div>
876
+
877
+ <div class="modal-actions" style="display:flex;gap:8px;justify-content:flex-end;margin-top:16px;">
878
+ <button onclick="tsReset()" style="background:transparent;color:var(--muted);border:1px solid var(--border);border-radius:5px;padding:6px 12px;font-size:12px;cursor:pointer;">Reset defaults</button>
879
+ <button onclick="document.getElementById('tsModal').style.display='none'" style="background:transparent;color:var(--muted);border:1px solid var(--border);border-radius:5px;padding:6px 12px;font-size:12px;cursor:pointer;">Close</button>
880
+ <button onclick="tsApply(true)" style="background:var(--accent);color:#fff;border:1px solid var(--accent);border-radius:5px;padding:6px 14px;font-size:12px;cursor:pointer;">Apply &amp; Save</button>
881
+ </div>
882
+ </div>
883
+ </div>
884
+
744
885
  <script src="https://cdn.jsdelivr.net/npm/xterm@5.3.0/lib/xterm.min.js"></script>
745
886
  <script src="https://cdn.jsdelivr.net/npm/xterm-addon-fit@0.8.0/lib/xterm-addon-fit.min.js"></script>
746
887
  <script src="https://cdn.jsdelivr.net/npm/xterm-addon-web-links@0.9.0/lib/xterm-addon-web-links.min.js"></script>
@@ -824,7 +965,184 @@ try { term.loadAddon(new WebLinksAddon.WebLinksAddon()); } catch(e){}
824
965
  term.open(document.getElementById('term-wrap'));
825
966
  setTimeout(function(){ try { fit.fit(); } catch(e){} }, 30);
826
967
 
827
- var ws = null, reconnectTimer = null;
968
+ // ─── Terminal Settings ────────────────────────────────────────
969
+ var TS_DEFAULTS = {
970
+ fontSize: 13, lineHeight: 1.25, fontFamily: '"SF Mono","Fira Code","JetBrains Mono",Menlo,ui-monospace,monospace',
971
+ cursorStyle: 'bar', cursorBlink: true, scrollback: 10000, ligatures: false,
972
+ bg: '#0a0e14', fg: '#e6edf3', cursor: '#58a6ff', sel: '#58a6ff'
973
+ };
974
+
975
+ var TS_THEMES = [
976
+ { name:'Sapper Dark', bg:'#0a0e14', fg:'#e6edf3', cursor:'#58a6ff', sel:'#58a6ff',
977
+ colors:['#0a0e14','#484f58','#ff7b72','#3fb950','#d29922','#58a6ff','#bc8cff','#39c5cf'] },
978
+ { name:'GitHub Dark', bg:'#161b22', fg:'#c9d1d9', cursor:'#58a6ff', sel:'#388bfd',
979
+ colors:['#161b22','#484f58','#ff7b72','#3fb950','#d29922','#58a6ff','#bc8cff','#39c5cf'] },
980
+ { name:'Dracula', bg:'#282a36', fg:'#f8f8f2', cursor:'#ff79c6', sel:'#44475a',
981
+ colors:['#282a36','#44475a','#ff5555','#50fa7b','#f1fa8c','#6272a4','#ff79c6','#8be9fd'] },
982
+ { name:'One Dark', bg:'#282c34', fg:'#abb2bf', cursor:'#528bff', sel:'#3e4451',
983
+ colors:['#282c34','#3e4451','#e06c75','#98c379','#e5c07b','#61afef','#c678dd','#56b6c2'] },
984
+ { name:'Monokai', bg:'#272822', fg:'#f8f8f2', cursor:'#f8f8f2', sel:'#49483e',
985
+ colors:['#272822','#75715e','#f92672','#a6e22e','#f4bf75','#66d9e8','#ae81ff','#a1efe4'] },
986
+ { name:'Solarized', bg:'#002b36', fg:'#839496', cursor:'#268bd2', sel:'#073642',
987
+ colors:['#002b36','#073642','#dc322f','#859900','#b58900','#268bd2','#d33682','#2aa198'] },
988
+ { name:'Nord', bg:'#2e3440', fg:'#d8dee9', cursor:'#88c0d0', sel:'#4c566a',
989
+ colors:['#2e3440','#4c566a','#bf616a','#a3be8c','#ebcb8b','#5e81ac','#b48ead','#88c0d0'] },
990
+ { name:'Light', bg:'#ffffff', fg:'#24292e', cursor:'#0366d6', sel:'#0366d6',
991
+ colors:['#ffffff','#f6f8fa','#d73a49','#22863a','#b08800','#0366d6','#6f42c1','#005cc5'] }
992
+ ];
993
+
994
+ var tsSettings = {};
995
+ try { tsSettings = JSON.parse(localStorage.getItem('sapperTermSettings') || '{}') || {}; } catch(e) {}
996
+
997
+ function tsGet(k) { return tsSettings[k] !== undefined ? tsSettings[k] : TS_DEFAULTS[k]; }
998
+
999
+ function tsApplyToTerm(s) {
1000
+ s = s || tsSettings;
1001
+ var fontSize = s.fontSize !== undefined ? s.fontSize : TS_DEFAULTS.fontSize;
1002
+ var lineHeight = s.lineHeight !== undefined ? s.lineHeight : TS_DEFAULTS.lineHeight;
1003
+ var fontFamily = s.fontFamily !== undefined ? s.fontFamily : TS_DEFAULTS.fontFamily;
1004
+ var cursorStyle = s.cursorStyle !== undefined ? s.cursorStyle : TS_DEFAULTS.cursorStyle;
1005
+ var cursorBlink = s.cursorBlink !== undefined ? s.cursorBlink : TS_DEFAULTS.cursorBlink;
1006
+ var scrollback = s.scrollback !== undefined ? s.scrollback : TS_DEFAULTS.scrollback;
1007
+ var bg = s.bg !== undefined ? s.bg : TS_DEFAULTS.bg;
1008
+ var fg = s.fg !== undefined ? s.fg : TS_DEFAULTS.fg;
1009
+ var cursor = s.cursor !== undefined ? s.cursor : TS_DEFAULTS.cursor;
1010
+ var sel = s.sel !== undefined ? s.sel : TS_DEFAULTS.sel;
1011
+
1012
+ term.options.fontSize = parseInt(fontSize, 10);
1013
+ term.options.lineHeight = parseFloat(lineHeight);
1014
+ term.options.fontFamily = fontFamily;
1015
+ term.options.cursorStyle = cursorStyle;
1016
+ term.options.cursorBlink = !!cursorBlink;
1017
+ term.options.scrollback = parseInt(scrollback, 10);
1018
+ term.options.theme = {
1019
+ background: bg, foreground: fg,
1020
+ cursor: cursor, cursorAccent: bg,
1021
+ selectionBackground: sel + '59',
1022
+ black:'#484f58', red:'#ff7b72', green:'#3fb950', yellow:'#d29922',
1023
+ blue:'#58a6ff', magenta:'#bc8cff', cyan:'#39c5cf', white:'#e6edf3',
1024
+ brightBlack:'#6e7681', brightRed:'#ffa198', brightGreen:'#56d364',
1025
+ brightYellow:'#e3b341', brightBlue:'#79c0ff', brightMagenta:'#d2a8ff',
1026
+ brightCyan:'#56d4dd', brightWhite:'#f0f6fc'
1027
+ };
1028
+ // Also update the page background to match terminal background
1029
+ document.documentElement.style.setProperty('--bg', bg);
1030
+ document.documentElement.style.setProperty('--fg', fg);
1031
+ document.documentElement.style.setProperty('--accent', cursor);
1032
+ document.documentElement.style.setProperty('--accent2', cursor);
1033
+ // Also update the xterm viewport background directly
1034
+ document.querySelectorAll('.xterm .xterm-viewport').forEach(function(el){ el.style.backgroundColor = bg; });
1035
+ try { fit.fit(); } catch(e) {}
1036
+ }
1037
+
1038
+ // Apply saved settings on load (if any)
1039
+ (function(){
1040
+ if (Object.keys(tsSettings).length > 0) tsApplyToTerm(tsSettings);
1041
+ })();
1042
+
1043
+ function tsColorInputPair(colorId, hexId) {
1044
+ var col = document.getElementById(colorId);
1045
+ var hex = document.getElementById(hexId);
1046
+ if (!col || !hex) return;
1047
+ col.addEventListener('input', function() {
1048
+ hex.value = col.value;
1049
+ });
1050
+ hex.addEventListener('change', function() {
1051
+ var v = hex.value.trim();
1052
+ if (/^#[0-9a-fA-F]{6}$/.test(v)) col.value = v;
1053
+ });
1054
+ hex.addEventListener('input', function() {
1055
+ var v = hex.value.trim();
1056
+ if (/^#[0-9a-fA-F]{6}$/.test(v)) col.value = v;
1057
+ });
1058
+ }
1059
+
1060
+ function tsApply(save) {
1061
+ var s = {
1062
+ fontSize: parseInt(document.getElementById('tsFontSz').value, 10),
1063
+ lineHeight: parseFloat(document.getElementById('tsLh').value),
1064
+ fontFamily: document.getElementById('tsFont').value,
1065
+ cursorStyle: document.getElementById('tsCursorStyle').value,
1066
+ cursorBlink: document.getElementById('tsCursorBlink').checked,
1067
+ scrollback: parseInt(document.getElementById('tsScrollback').value, 10),
1068
+ ligatures: document.getElementById('tsLigatures').checked,
1069
+ bg: document.getElementById('tsBgHex').value || document.getElementById('tsBg').value,
1070
+ fg: document.getElementById('tsFgHex').value || document.getElementById('tsFg').value,
1071
+ cursor: document.getElementById('tsCursorHex').value || document.getElementById('tsCursor').value,
1072
+ sel: document.getElementById('tsSelHex').value || document.getElementById('tsSel').value,
1073
+ };
1074
+ tsApplyToTerm(s);
1075
+ if (save) {
1076
+ tsSettings = s;
1077
+ try { localStorage.setItem('sapperTermSettings', JSON.stringify(s)); } catch(e) {}
1078
+ document.getElementById('tsModal').style.display = 'none';
1079
+ showToast('Terminal settings saved');
1080
+ }
1081
+ }
1082
+
1083
+ function tsReset() {
1084
+ tsSettings = {};
1085
+ try { localStorage.removeItem('sapperTermSettings'); } catch(e) {}
1086
+ tsApplyToTerm(TS_DEFAULTS);
1087
+ tsLoadIntoForm(TS_DEFAULTS);
1088
+ showToast('Reset to defaults');
1089
+ }
1090
+
1091
+ function tsLoadIntoForm(s) {
1092
+ var get = function(k){ return s[k] !== undefined ? s[k] : TS_DEFAULTS[k]; };
1093
+ document.getElementById('tsFontSz').value = get('fontSize');
1094
+ document.getElementById('tsFontSzV').textContent = get('fontSize');
1095
+ document.getElementById('tsLh').value = get('lineHeight');
1096
+ document.getElementById('tsLhV').textContent = parseFloat(get('lineHeight')).toFixed(2);
1097
+ document.getElementById('tsFont').value = get('fontFamily');
1098
+ document.getElementById('tsCursorStyle').value = get('cursorStyle');
1099
+ document.getElementById('tsCursorBlink').checked = !!get('cursorBlink');
1100
+ document.getElementById('tsScrollback').value = get('scrollback');
1101
+ document.getElementById('tsLigatures').checked = !!get('ligatures');
1102
+ var bg = get('bg'); document.getElementById('tsBg').value = bg; document.getElementById('tsBgHex').value = bg;
1103
+ var fg = get('fg'); document.getElementById('tsFg').value = fg; document.getElementById('tsFgHex').value = fg;
1104
+ var c = get('cursor'); document.getElementById('tsCursor').value = c; document.getElementById('tsCursorHex').value = c;
1105
+ var sl = get('sel'); document.getElementById('tsSel').value = sl; document.getElementById('tsSelHex').value = sl;
1106
+ }
1107
+
1108
+ function tsBuildSwatches() {
1109
+ var cont = document.getElementById('tsSwatches');
1110
+ if (!cont) return;
1111
+ cont.innerHTML = TS_THEMES.map(function(t, i) {
1112
+ var grad = 'background:linear-gradient(135deg,' + t.bg + ' 55%,' + t.cursor + ' 100%)';
1113
+ return '<div class="ts-swatch" data-i="' + i + '" title="' + t.name + '">' +
1114
+ '<div class="tsb" style="' + grad + '"></div>' +
1115
+ '<span>' + t.name + '</span></div>';
1116
+ }).join('');
1117
+ cont.querySelectorAll('.ts-swatch').forEach(function(el) {
1118
+ el.addEventListener('click', function() {
1119
+ cont.querySelectorAll('.ts-swatch').forEach(function(e){ e.classList.remove('active'); });
1120
+ el.classList.add('active');
1121
+ var t = TS_THEMES[parseInt(el.getAttribute('data-i'), 10)];
1122
+ document.getElementById('tsBg').value = t.bg; document.getElementById('tsBgHex').value = t.bg;
1123
+ document.getElementById('tsFg').value = t.fg; document.getElementById('tsFgHex').value = t.fg;
1124
+ document.getElementById('tsCursor').value = t.cursor; document.getElementById('tsCursorHex').value = t.cursor;
1125
+ document.getElementById('tsSel').value = t.sel; document.getElementById('tsSelHex').value = t.sel;
1126
+ tsApply(false); // live preview
1127
+ });
1128
+ });
1129
+ }
1130
+
1131
+ window.openTermSettings = function() {
1132
+ var modal = document.getElementById('tsModal');
1133
+ tsLoadIntoForm(tsSettings);
1134
+ tsBuildSwatches();
1135
+ // Wire color input pairs
1136
+ tsColorInputPair('tsBg','tsBgHex');
1137
+ tsColorInputPair('tsFg','tsFgHex');
1138
+ tsColorInputPair('tsCursor','tsCursorHex');
1139
+ tsColorInputPair('tsSel','tsSelHex');
1140
+ modal.style.display = 'flex';
1141
+ // Close on backdrop click
1142
+ modal.onclick = function(ev){ if (ev.target === modal) modal.style.display = 'none'; };
1143
+ };
1144
+
1145
+
828
1146
 
829
1147
  function connectPty() {
830
1148
  var proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
@@ -1895,9 +2213,19 @@ function renderIndex() {
1895
2213
  var info = state.indexSet[p];
1896
2214
  var cls = info.isDir ? 'chip dir' : 'chip';
1897
2215
  var ico = info.isDir ? '&#128193;' : '&#128462;';
2216
+ var deepBtn = '';
2217
+ if (info.isDir) {
2218
+ var deepOn = info.deep ? ' on' : '';
2219
+ var deepLabel = info.deep ? 'deep' : 'scan';
2220
+ var deepTitle = info.deep
2221
+ ? 'Deep: include the contents of every readable file in this folder'
2222
+ : 'Scan: list the folder and include code-file contents (smaller payload). Click to switch to deep.';
2223
+ deepBtn = '<span class="cdeep' + deepOn + '" data-p="' + esc(p) + '" title="' + deepTitle + '">' + deepLabel + '</span>';
2224
+ }
1898
2225
  return '<span class="' + cls + '" title="' + esc(p) + '">' +
1899
2226
  '<span>' + ico + '</span>' +
1900
2227
  '<span class="cp">' + esc(p) + '</span>' +
2228
+ deepBtn +
1901
2229
  '<span class="cx" data-p="' + esc(p) + '" title="Remove">&times;</span>' +
1902
2230
  '</span>';
1903
2231
  }).join('');
@@ -1907,23 +2235,42 @@ function renderIndex() {
1907
2235
  toggleIndex(el.getAttribute('data-p'), state.indexSet[el.getAttribute('data-p')] && state.indexSet[el.getAttribute('data-p')].isDir);
1908
2236
  });
1909
2237
  });
2238
+ chips.querySelectorAll('.cdeep').forEach(function(el){
2239
+ el.addEventListener('click', function(ev){
2240
+ ev.stopPropagation();
2241
+ var p = el.getAttribute('data-p');
2242
+ var info = state.indexSet[p];
2243
+ if (!info || !info.isDir) return;
2244
+ info.deep = !info.deep;
2245
+ saveIndex();
2246
+ renderIndex();
2247
+ });
2248
+ });
1910
2249
  }
1911
2250
 
1912
2251
  window.sendIndexToChat = function() {
1913
2252
  var paths = Object.keys(state.indexSet);
1914
2253
  if (!paths.length) { showToast('Index is empty', 'err'); return; }
1915
2254
  if (!ws || ws.readyState !== 1) { showToast('Terminal not connected', 'err'); return; }
1916
- var files = [], dirs = [];
2255
+ var files = [], dirsShallow = [], dirsDeep = [];
1917
2256
  paths.forEach(function(p){
1918
- if (state.indexSet[p] && state.indexSet[p].isDir) dirs.push(p); else files.push(p);
2257
+ var info = state.indexSet[p];
2258
+ if (!info) return;
2259
+ if (info.isDir) {
2260
+ if (info.deep) dirsDeep.push(p); else dirsShallow.push(p);
2261
+ } else {
2262
+ files.push(p);
2263
+ }
1919
2264
  });
1920
2265
  // Helper: quote paths that contain spaces, leave plain otherwise.
1921
2266
  function quoteIfNeeded(p) {
1922
2267
  return /\\s/.test(p) ? '"' + p.replace(/"/g, '\\\\"') + '"' : p;
1923
2268
  }
1924
- // 1) /scan each folder (each sent as its own command + Enter)
1925
- dirs.forEach(function(d){ sendPasteToTerm('/scan ' + quoteIfNeeded(d)); });
1926
- // 2) Build attachments token for files
2269
+ // 1) /scan for shallow folders (file tree + code-file contents)
2270
+ dirsShallow.forEach(function(d){ sendPasteToTerm('/scan ' + quoteIfNeeded(d)); });
2271
+ // 2) /include for deep folders (every readable file's contents)
2272
+ dirsDeep.forEach(function(d){ sendPasteToTerm('/include ' + quoteIfNeeded(d)); });
2273
+ // 3) Build attachments token for files
1927
2274
  var atTokens = files.map(function(f){ return '@' + quoteIfNeeded(f); }).join(' ');
1928
2275
  var comment = (document.getElementById('idxComment') || {}).value || '';
1929
2276
  comment = comment.trim();
@@ -1936,8 +2283,10 @@ window.sendIndexToChat = function() {
1936
2283
  // Stage at cursor — no Enter, so the user can type their question
1937
2284
  sendRawToTerm(atTokens + ' ');
1938
2285
  }
2286
+ var dirsTotal = dirsShallow.length + dirsDeep.length;
1939
2287
  showToast('Sent ' + files.length + ' file' + (files.length === 1 ? '' : 's') +
1940
- (dirs.length ? ' and ' + dirs.length + ' folder' + (dirs.length === 1 ? '' : 's') : '') +
2288
+ (dirsTotal ? ' and ' + dirsTotal + ' folder' + (dirsTotal === 1 ? '' : 's') +
2289
+ (dirsDeep.length ? ' (' + dirsDeep.length + ' deep)' : '') : '') +
1941
2290
  ' to chat');
1942
2291
  // Clear comment, clear index, refocus terminal
1943
2292
  var cmt = document.getElementById('idxComment'); if (cmt) cmt.value = '';
package/sapper.mjs CHANGED
@@ -1019,6 +1019,12 @@ let _useNativeToolsFlag = false;
1019
1019
  // Models known to reject `think:true` (populated lazily on first failure).
1020
1020
  const modelsWithoutThinking = new Set();
1021
1021
 
1022
+ // Models known to emit malformed native tool calls (e.g. broken XML that
1023
+ // Ollama's parser rejects with "element <function> closed by </parameter>").
1024
+ // Once a model lands here we stop sending `tools` to it for the rest of the
1025
+ // session — it falls back to text-marker tools, which any model can produce.
1026
+ const modelsWithBrokenNativeTools = new Set();
1027
+
1022
1028
  function buildSystemPrompt(agentContent = null, skillContents = []) {
1023
1029
  const now = new Date();
1024
1030
  const dateStr = now.toLocaleDateString('en-US', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' });
@@ -3013,7 +3019,8 @@ const COMMAND_GROUPS = Object.freeze([
3013
3019
  rows: [
3014
3020
  ['@ or /attach', 'Pick files to attach interactively'],
3015
3021
  ['@file', 'Attach a file inline, for example @src/app.js'],
3016
- ['/scan', 'Scan the codebase into context'],
3022
+ ['/scan [path]', 'Scan a folder (code files only) into context'],
3023
+ ['/include [path]', 'Attach every readable file in a folder (deep)'],
3017
3024
  ['/index', 'Rebuild the workspace graph'],
3018
3025
  ['/graph file', 'Show related files from the graph'],
3019
3026
  ['/symbol name', 'Search indexed functions and classes'],
@@ -4185,6 +4192,50 @@ function scanCodebase(dir = '.', depth = 0, maxDepth = 5) {
4185
4192
  return { files, totalSize };
4186
4193
  }
4187
4194
 
4195
+ // Walk a folder and read every readable text file's contents (broader than
4196
+ // scanCodebase, which is restricted to CODE_EXTENSIONS). Used by /include
4197
+ // when the user wants ALL files in a folder attached (not just a tree+code).
4198
+ function includeFolderContents(dir, depth = 0, maxDepth = 8, acc = null) {
4199
+ acc = acc || { files: [], totalSize: 0, skipped: [], reachedCap: false };
4200
+ if (acc.reachedCap || depth > maxDepth) return acc;
4201
+ let entries;
4202
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); }
4203
+ catch (e) { return acc; }
4204
+ for (const entry of entries) {
4205
+ if (acc.reachedCap) break;
4206
+ const fullPath = dir === '.' ? entry.name : `${dir}/${entry.name}`;
4207
+ if (entry.isDirectory()) {
4208
+ if (shouldIgnore(entry.name) || entry.name.startsWith('.')) continue;
4209
+ includeFolderContents(fullPath, depth + 1, maxDepth, acc);
4210
+ continue;
4211
+ }
4212
+ if (!entry.isFile()) continue;
4213
+ if (shouldIgnore(fullPath) || shouldIgnore(entry.name)) continue;
4214
+ let stats;
4215
+ try { stats = fs.statSync(fullPath); } catch (e) { continue; }
4216
+ if (stats.size > getMaxFileSize()) {
4217
+ acc.skipped.push({ path: fullPath, size: stats.size, reason: 'too large' });
4218
+ continue;
4219
+ }
4220
+ if (acc.totalSize + stats.size > getMaxScanSize()) {
4221
+ acc.skipped.push({ path: fullPath, size: stats.size, reason: 'total limit reached' });
4222
+ acc.reachedCap = true;
4223
+ continue;
4224
+ }
4225
+ let content;
4226
+ try { content = fs.readFileSync(fullPath, 'utf8'); }
4227
+ catch (e) { acc.skipped.push({ path: fullPath, reason: e.message }); continue; }
4228
+ // Skip binary-looking files (NUL byte in first 4KB)
4229
+ if (content.length && content.slice(0, 4096).indexOf('\u0000') !== -1) {
4230
+ acc.skipped.push({ path: fullPath, size: stats.size, reason: 'binary' });
4231
+ continue;
4232
+ }
4233
+ acc.files.push({ path: fullPath, size: stats.size, content });
4234
+ acc.totalSize += stats.size;
4235
+ }
4236
+ return acc;
4237
+ }
4238
+
4188
4239
  // Scan directory for files (for @ file picker)
4189
4240
  function getFilesForPicker(dir = '.', prefix = '', maxFiles = 50) {
4190
4241
  let files = [];
@@ -6263,6 +6314,12 @@ async function runSapper() {
6263
6314
  } else {
6264
6315
  toolModeLabel = 'text markers';
6265
6316
  }
6317
+ // If we previously caught this model emitting malformed tool XML, stay
6318
+ // on text-marker tools for the whole session.
6319
+ if (modelsWithBrokenNativeTools.has(selectedModel)) {
6320
+ useNativeTools = false;
6321
+ toolModeLabel = 'text markers (native tools disabled — model emits broken XML)';
6322
+ }
6266
6323
  // Extract context window size from model_info
6267
6324
  // Different model families use different keys: llama.context_length, qwen2.context_length, etc.
6268
6325
  if (modelInfo.model_info) {
@@ -8424,6 +8481,61 @@ async function runSapper() {
8424
8481
  continue;
8425
8482
  }
8426
8483
 
8484
+ // /include <path> — deep-attach every readable file in a folder
8485
+ // (broader than /scan: not limited to code extensions). Used by the
8486
+ // Index tray's "deep" toggle on folder chips.
8487
+ if (input.toLowerCase() === '/include' || input.toLowerCase().startsWith('/include ')) {
8488
+ let incTarget = '.';
8489
+ const incRest = input.slice(8).trim();
8490
+ if (incRest) {
8491
+ if ((incRest.startsWith('"') && incRest.endsWith('"')) ||
8492
+ (incRest.startsWith("'") && incRest.endsWith("'"))) {
8493
+ incTarget = incRest.slice(1, -1);
8494
+ } else {
8495
+ incTarget = incRest;
8496
+ }
8497
+ }
8498
+ if (!fs.existsSync(incTarget)) {
8499
+ console.log(chalk.red(`Path not found: ${incTarget}`));
8500
+ continue;
8501
+ }
8502
+ const incStat = fs.statSync(incTarget);
8503
+ if (!incStat.isDirectory()) {
8504
+ console.log(chalk.red(`Not a directory: ${incTarget}`));
8505
+ continue;
8506
+ }
8507
+ console.log(uiCleanMode()
8508
+ ? chalk.cyan(`\nIncluding all files in ${incTarget}...`)
8509
+ : chalk.cyan(`\n📚 Including all files in ${incTarget}...`));
8510
+ const incResult = includeFolderContents(incTarget);
8511
+ if (incResult.files.length === 0) {
8512
+ console.log(chalk.yellow(`No readable files found in ${incTarget}.`));
8513
+ if (incResult.skipped.length) {
8514
+ console.log(chalk.gray(`(${incResult.skipped.length} skipped — too large, binary, or ignored)`));
8515
+ }
8516
+ continue;
8517
+ }
8518
+ const incBlock = formatFileAttachments(incResult.files);
8519
+ messages.push({
8520
+ role: 'user',
8521
+ content: `Including all readable files from ${incTarget}:\n${incBlock}`
8522
+ });
8523
+ console.log(uiCleanMode()
8524
+ ? chalk.green(`Included ${incResult.files.length} files (~${Math.round(incResult.totalSize/1024)}KB) from ${incTarget}`)
8525
+ : chalk.green(`✅ Included ${incResult.files.length} files (~${Math.round(incResult.totalSize/1024)}KB) from ${incTarget}`));
8526
+ if (incResult.skipped.length > 0) {
8527
+ console.log(uiCleanMode()
8528
+ ? chalk.yellow(`Skipped ${incResult.skipped.length} files (too large, binary, or limit reached)`)
8529
+ : chalk.yellow(`⏭️ Skipped ${incResult.skipped.length} files (too large, binary, or limit reached)`));
8530
+ }
8531
+ if (incResult.reachedCap) {
8532
+ console.log(chalk.yellow(`⚠️ Hit total-size cap (~${Math.round(getMaxScanSize()/1024)}KB); some files were not included.`));
8533
+ }
8534
+ ensureSapperDir();
8535
+ fs.writeFileSync(CONTEXT_FILE, JSON.stringify(messages, null, 2));
8536
+ continue;
8537
+ }
8538
+
8427
8539
  if (input.startsWith('/') && !input.startsWith('//') && !agentHandled) {
8428
8540
  const commandToken = input.slice(1).trim().split(/\s+/)[0] || '';
8429
8541
  const suggestions = suggestSlashCommands(commandToken, 5);
@@ -8654,6 +8766,9 @@ async function runSapper() {
8654
8766
  let turnThinkingEnabled = shouldUseThinkingForInput(input);
8655
8767
  if (modelsWithoutThinking.has(selectedModel)) turnThinkingEnabled = false;
8656
8768
 
8769
+ // Auto-recover once per turn if the model emits malformed native tool XML.
8770
+ let nativeToolXmlRetried = false;
8771
+
8657
8772
  let active = true;
8658
8773
  while (active) {
8659
8774
  if (stepMode) await safeQuestion(chalk.gray(promptLabel('questions.stepContinue', '[STEP] Press Enter to let AI think...')));
@@ -8866,8 +8981,31 @@ async function runSapper() {
8866
8981
  process.stdout.write(`\n${UI.slate(' └─')}\n`);
8867
8982
  }
8868
8983
  process.stdout.write('\r\x1b[K');
8869
- console.error(chalk.red(`\n❌ Stream error: ${streamErrored.message || streamErrored}`));
8870
- logEntry('error', { message: `Stream error: ${streamErrored.message || streamErrored}` });
8984
+ const streamErrMsg = streamErrored.message || String(streamErrored);
8985
+ // Detect malformed native-tool XML from small/quantized models.
8986
+ // Ollama's parser surfaces this as e.g. "XML syntax error on line 3:
8987
+ // element <function> closed by </parameter>". Disable native tools
8988
+ // for this model and retry the same turn once.
8989
+ const isXmlToolErr = /xml syntax|element <\w+> closed by|invalid character|unexpected end of/i.test(streamErrMsg);
8990
+ if (isXmlToolErr && useNativeTools && !nativeToolXmlRetried) {
8991
+ nativeToolXmlRetried = true;
8992
+ modelsWithBrokenNativeTools.add(selectedModel);
8993
+ useNativeTools = false;
8994
+ toolModeLabel = 'text markers (auto: model emits broken tool XML)';
8995
+ console.error(chalk.yellow(
8996
+ `\n⚠ "${selectedModel}" emitted malformed tool XML — disabling native tool calling for this session and retrying.`
8997
+ ));
8998
+ console.error(chalk.gray(' Tip: a larger / instruction-tuned model (e.g. qwen2.5:7b, llama3.1) will not hit this.'));
8999
+ logEntry('warn', { message: `Disabled native tools for ${selectedModel}: ${streamErrMsg}` });
9000
+ // Reset per-attempt state and try this same turn again without tools.
9001
+ msg = '';
9002
+ thinkMsg = '';
9003
+ nativeToolCalls = [];
9004
+ streamErrored = null;
9005
+ continue;
9006
+ }
9007
+ console.error(chalk.red(`\n❌ Stream error: ${streamErrMsg}`));
9008
+ logEntry('error', { message: `Stream error: ${streamErrMsg}` });
8871
9009
  active = false;
8872
9010
  continue;
8873
9011
  }