sapper-iq 1.4.0 → 1.4.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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/sapper-ui.mjs +151 -51
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sapper-iq",
3
- "version": "1.4.0",
3
+ "version": "1.4.2",
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
@@ -628,6 +628,35 @@ function buildHTML() {
628
628
  .tmsg.warn { border-color: rgba(210,153,34,.5); }
629
629
  .tmsg.err { border-color: var(--red); }
630
630
  @keyframes slideIn { from { transform: translateX(10px); opacity: 0; } to { transform: none; opacity: 1; } }
631
+
632
+ /* ─── Tooltip (! icon) ─── */
633
+ .tip {
634
+ display: inline-flex; align-items: center; justify-content: center;
635
+ width: 13px; height: 13px; border-radius: 50%;
636
+ border: 1px solid var(--border2); color: var(--dim);
637
+ font-size: 9px; font-weight: 700; cursor: help;
638
+ flex-shrink: 0; user-select: none; position: relative;
639
+ vertical-align: middle; margin-left: 4px; line-height: 1;
640
+ transition: color .12s, border-color .12s;
641
+ }
642
+ .tip:hover { color: var(--accent); border-color: var(--accent); }
643
+ .tip::after {
644
+ content: attr(data-tip);
645
+ position: absolute;
646
+ bottom: calc(100% + 7px);
647
+ left: 50%; transform: translateX(-50%);
648
+ background: var(--panel2); color: var(--fg);
649
+ border: 1px solid var(--border2);
650
+ border-radius: 6px; padding: 6px 10px;
651
+ font-size: 11px; font-weight: 400;
652
+ white-space: normal; width: 220px; line-height: 1.45;
653
+ pointer-events: none; opacity: 0;
654
+ z-index: 99999; box-shadow: 0 6px 20px rgba(0,0,0,.5);
655
+ transition: opacity .15s;
656
+ }
657
+ .tip:hover::after { opacity: 1; }
658
+ /* keep tip visible when near top of panel (flip below) */
659
+ .tip.below::after { bottom: auto; top: calc(100% + 7px); }
631
660
  </style>
632
661
  </head>
633
662
  <body>
@@ -656,17 +685,17 @@ function buildHTML() {
656
685
  <!-- Sidebar -->
657
686
  <aside id="side">
658
687
  <div class="tabs">
659
- <button class="active" data-tab="files" onclick="switchTab('files')">Files</button>
660
- <button data-tab="config" onclick="switchTab('config')">Config</button>
661
- <button data-tab="agents" onclick="switchTab('agents')">Agents</button>
662
- <button data-tab="skills" onclick="switchTab('skills')">Skills</button>
688
+ <button class="active" data-tab="files" onclick="switchTab('files')" title="Browse and manage workspace files">Files</button>
689
+ <button data-tab="config" onclick="switchTab('config')" title="Edit Sapper config.json settings">Config</button>
690
+ <button data-tab="agents" onclick="switchTab('agents')" title="Manage AI agent roles (.sapper/agents/)">Agents</button>
691
+ <button data-tab="skills" onclick="switchTab('skills')" title="Manage skills loaded into the AI (.sapper/skills/)">Skills</button>
663
692
  </div>
664
693
  <div class="pane active" id="pane-files">
665
694
  <div class="files-toolbar">
666
- <button class="ftb" title="New file" onclick="newItemPrompt('file','')">&#128462;<sup>+</sup></button>
667
- <button class="ftb" title="New folder" onclick="newItemPrompt('folder','')">&#128193;<sup>+</sup></button>
668
- <button class="ftb" id="ftbAct" title="Show activity log" onclick="toggleActivity()">&#9737;</button>
669
- <button class="ftb" id="ftbIdx" title="Index files/folders into chat (multi-select)" onclick="toggleIndexMode()">&#128218;</button>
695
+ <button class="ftb" title="Create a new file in the current directory" onclick="newItemPrompt('file','')">&#128462;<sup>+</sup></button>
696
+ <button class="ftb" title="Create a new folder in the current directory" onclick="newItemPrompt('folder','')">&#128193;<sup>+</sup></button>
697
+ <button class="ftb" id="ftbAct" title="Toggle activity feed — shows files recently created, modified, or deleted by Sapper" onclick="toggleActivity()">&#9737;</button>
698
+ <button class="ftb" id="ftbIdx" title="Index mode — check files/folders to bundle them into a single chat message" onclick="toggleIndexMode()">&#128218;</button>
670
699
  <span class="ftb-spacer"></span>
671
700
  <button class="ftb" title="Clear change marks" onclick="clearAllMarks()">&#10005;</button>
672
701
  <button class="ftb" title="Refresh tree" onclick="loadTree()">&#8634;</button>
@@ -725,18 +754,18 @@ function buildHTML() {
725
754
  <!-- Center: terminal -->
726
755
  <main id="center">
727
756
  <div id="qa">
728
- <button class="qabtn" title="Attach files (sends @path to Sapper)" onclick="pickAndUpload()">
757
+ <button class="qabtn" title="Pick local files to attach — sends @filepath reference(s) into the terminal" onclick="pickAndUpload()">
729
758
  <span class="qaico">&#128206;</span><span class="qalbl">Attach</span>
730
759
  </button>
731
- <button class="qabtn rec" title="Record voice (auto-transcribed by Sapper)" onclick="toggleRecord()" id="qaRec">
760
+ <button class="qabtn rec" title="Record a voice message Sapper transcribes it with Whisper and sends it as a prompt" onclick="toggleRecord()" id="qaRec">
732
761
  <span class="qaico">&#127908;</span><span class="qalbl">Record</span>
733
762
  </button>
734
763
  <span id="recDot" class="rec-dot"></span>
735
764
  <span id="recTime" class="rec-time"></span>
736
765
  <span class="qa-sp"></span>
737
- <button class="qabtn" title="Send /attach (interactive)" onclick="sendCmd('/attach')">/attach</button>
738
- <button class="qabtn" title="Open file by path" onclick="sendOpenPrompt()">/open</button>
739
- <button class="qabtn" title="Compact context" onclick="sendCmd('/summary')">/summary</button>
766
+ <button class="qabtn" title="Run /attach inside the terminal — lets Sapper interactively pick files with fuzzy search" onclick="sendCmd('/attach')">/attach</button>
767
+ <button class="qabtn" title="Open a file by path in the preview panel" onclick="sendOpenPrompt()">/open</button>
768
+ <button class="qabtn" title="Summarize and compress the conversation context to free up token space" onclick="sendCmd('/summary')">/summary</button>
740
769
  <input type="file" id="qaFile" multiple style="display:none">
741
770
  </div>
742
771
  <div id="term-wrap"></div>
@@ -2478,14 +2507,15 @@ function renderQuickConfig(cfg) {
2478
2507
  var host = document.getElementById('cfgQuickBody');
2479
2508
  host.innerHTML = '';
2480
2509
  function add(html) { host.insertAdjacentHTML('beforeend', html); }
2481
- add('<label>Default model</label><input type="text" id="qDefMod" placeholder="auto" value="' + esc(cfg.defaultModel || '') + '">');
2482
- add('<label>Default agent</label><input type="text" id="qDefAgent" placeholder="(none)" value="' + esc(cfg.defaultAgent || '') + '">');
2483
- add('<label>Context limit (tokens, blank = model default)</label><input type="number" id="qCtxLim" value="' + esc(cfg.contextLimit == null ? '' : cfg.contextLimit) + '">');
2484
- add('<label>Tool round limit</label><input type="number" id="qToolRnd" value="' + esc(cfg.toolRoundLimit != null ? cfg.toolRoundLimit : 40) + '">');
2485
- add('<div class="toggle-row"><span>Summary phases</span><div class="switch ' + (cfg.summaryPhases ? 'on' : '') + '" id="qSumPh"></div></div>');
2486
- add('<label>Summary trigger %</label><input type="number" id="qSumTr" value="' + esc(cfg.summarizeTriggerPercent != null ? cfg.summarizeTriggerPercent : 65) + '">');
2487
- add('<div class="toggle-row"><span>Debug mode</span><div class="switch ' + (cfg.debug ? 'on' : '') + '" id="qDebug"></div></div>');
2488
- add('<div class="toggle-row"><span>Auto-attach files</span><div class="switch ' + (cfg.autoAttach !== false ? 'on' : '') + '" id="qAutoAtt"></div></div>');
2510
+ var T = function(tip, below) { return '<span class="tip' + (below ? ' below' : '') + '" data-tip="' + tip.replace(/"/g, '&quot;') + '">!</span>'; };
2511
+ add('<label>Default model ' + T('The AI model used when no model is specified at startup. E.g. gpt-4o, claude-3-5-sonnet, qwen3.5. Leave blank to be prompted on launch.') + '</label><input type="text" id="qDefMod" placeholder="auto" value="' + esc(cfg.defaultModel || '') + '">');
2512
+ add('<label>Default agent ' + T('Agent role (.sapper/agents/*.md) to activate automatically when Sapper starts. Leave blank for the default general-purpose assistant.') + '</label><input type="text" id="qDefAgent" placeholder="(none)" value="' + esc(cfg.defaultAgent || '') + '">');
2513
+ add('<label>Context limit ' + T('Hard cap on tokens sent to the model per request. Leave blank to use the model\'s full context window. Useful to reduce cost or avoid slow responses.') + '</label><input type="number" id="qCtxLim" value="' + esc(cfg.contextLimit == null ? '' : cfg.contextLimit) + '">');
2514
+ add('<label>Tool round limit ' + T('Maximum number of tool calls (file reads, shell commands, patches…) Sapper may make in a single response turn. Default is 40. Lower to prevent runaway loops.') + '</label><input type="number" id="qToolRnd" value="' + esc(cfg.toolRoundLimit != null ? cfg.toolRoundLimit : 40) + '">');
2515
+ add('<div class="toggle-row"><span>Summary phases ' + T('When ON, Sapper displays a step-by-step progress bar while it compresses long conversations. Turn OFF for a quieter experience.') + '</span><div class="switch ' + (cfg.summaryPhases ? 'on' : '') + '" id="qSumPh"></div></div>');
2516
+ add('<label>Summary trigger % ' + T('When the conversation reaches this percentage of the context window, Sapper automatically summarizes older messages to keep the window from overflowing. Default 65%.') + '</label><input type="number" id="qSumTr" value="' + esc(cfg.summarizeTriggerPercent != null ? cfg.summarizeTriggerPercent : 65) + '">');
2517
+ add('<div class="toggle-row"><span>Debug mode ' + T('Enables verbose output — shows raw tool call details, API request sizes, and internal errors. Useful for troubleshooting but noisy during normal use.') + '</span><div class="switch ' + (cfg.debug ? 'on' : '') + '" id="qDebug"></div></div>');
2518
+ add('<div class="toggle-row"><span>Auto-attach files ' + T('When ON, files you open in the sidebar are automatically referenced in the AI context so Sapper knows what you are looking at without you typing @filename.') + '</span><div class="switch ' + (cfg.autoAttach !== false ? 'on' : '') + '" id="qAutoAtt"></div></div>');
2489
2519
  add('<div class="row-btns"><button class="primary" onclick="saveQuickConfig()">Apply quick changes</button></div>');
2490
2520
 
2491
2521
  function bindSwitch(id) {
@@ -2837,13 +2867,20 @@ function listEntries(dirPath) {
2837
2867
 
2838
2868
  function looksBinary(buf) {
2839
2869
  const len = Math.min(buf.length, 4096);
2840
- let nonText = 0;
2870
+ // Null byte is a definitive binary indicator
2841
2871
  for (let i = 0; i < len; i++) {
2842
- const c = buf[i];
2843
- if (c === 0) return true;
2844
- if ((c < 32 && c !== 9 && c !== 10 && c !== 13) || c >= 127) nonText++;
2872
+ if (buf[i] === 0) return true;
2873
+ }
2874
+ // Try decoding as UTF-8; replacement char U+FFFD signals invalid sequences (binary)
2875
+ const sample = buf.slice(0, len).toString('utf8');
2876
+ if (sample.includes('\uFFFD')) return true;
2877
+ // Count true non-printable control chars (bytes < 32 excluding tab/LF/CR)
2878
+ let nonText = 0;
2879
+ for (let i = 0; i < sample.length; i++) {
2880
+ const c = sample.charCodeAt(i);
2881
+ if (c < 32 && c !== 9 && c !== 10 && c !== 13) nonText++;
2845
2882
  }
2846
- return nonText / Math.max(len, 1) > 0.3;
2883
+ return nonText / Math.max(sample.length, 1) > 0.1;
2847
2884
  }
2848
2885
 
2849
2886
  const server = http.createServer(async (req, res) => {
@@ -3126,41 +3163,98 @@ function spawnSapper(cols, rows) {
3126
3163
  });
3127
3164
  }
3128
3165
 
3166
+ // ─── Persistent PTY (survives browser refresh) ───────────────────
3167
+ // The pty process lives at module scope so it outlives any WS connection.
3168
+ // All output is stored in a ring buffer; new clients replay it on connect.
3169
+
3170
+ const PTY_SCROLLBACK_MAX = 512 * 1024; // 512 KB replay buffer
3171
+ let sharedPty = null;
3172
+ let ptyScrollback = ''; // raw bytes (utf-8) for replay
3173
+ let ptyCols = 220, ptyRows = 50; // last known size
3174
+
3175
+ function appendPtyScrollback(chunk) {
3176
+ ptyScrollback += chunk;
3177
+ if (ptyScrollback.length > PTY_SCROLLBACK_MAX) {
3178
+ // Drop oldest half to stay near cap without constant slicing
3179
+ ptyScrollback = ptyScrollback.slice(ptyScrollback.length - Math.floor(PTY_SCROLLBACK_MAX * 0.6));
3180
+ }
3181
+ }
3182
+
3183
+ function ensurePty(cols, rows) {
3184
+ if (sharedPty) return;
3185
+ ptyCols = cols || 220; ptyRows = rows || 50;
3186
+ try {
3187
+ sharedPty = spawnSapper(ptyCols, ptyRows);
3188
+ } catch (e) {
3189
+ console.error('[ui] spawn failed:', e.message);
3190
+ return;
3191
+ }
3192
+ dbg('pty pid=' + sharedPty.pid + ' ' + ptyCols + 'x' + ptyRows);
3193
+ sharedPty.onData((d) => {
3194
+ appendPtyScrollback(d);
3195
+ // Broadcast to every connected pty client
3196
+ for (const ws of ptyClients) {
3197
+ if (ws.readyState === ws.OPEN) {
3198
+ try { ws.send(Buffer.from(d, 'utf8')); } catch {}
3199
+ }
3200
+ }
3201
+ });
3202
+ sharedPty.onExit(({ exitCode, signal }) => {
3203
+ dbg('pty exit code=' + exitCode);
3204
+ sharedPty = null;
3205
+ // Notify all clients so they can show "exited" badge
3206
+ const msg = JSON.stringify({ type: 'exit', code: exitCode, signal });
3207
+ for (const ws of ptyClients) {
3208
+ if (ws.readyState === ws.OPEN) { try { ws.send(msg); } catch {} }
3209
+ }
3210
+ });
3211
+ }
3212
+
3213
+ const ptyClients = new Set(); // all currently-connected pty websockets
3214
+
3129
3215
  wssPty.on('connection', (ws) => {
3130
3216
  dbg('pty client connected');
3131
- let pty = null; let initialized = false;
3132
-
3133
- function start(cols, rows) {
3134
- if (pty) { try { pty.kill(); } catch {} }
3135
- try { pty = spawnSapper(cols, rows); }
3136
- catch (e) {
3137
- console.error('[ui] spawn failed:', e.message);
3138
- try { ws.send(Buffer.from('\x1b[31mFailed to spawn sapper: ' + e.message + '\x1b[0m\r\n', 'utf8')); } catch {}
3139
- return;
3140
- }
3141
- dbg('pty pid=' + pty.pid + ' ' + cols + 'x' + rows);
3142
- pty.onData((d) => { if (ws.readyState === ws.OPEN) ws.send(Buffer.from(d, 'utf8')); });
3143
- pty.onExit(({ exitCode, signal }) => {
3144
- dbg('pty exit code=' + exitCode);
3145
- if (ws.readyState === ws.OPEN) { try { ws.send(JSON.stringify({ type: 'exit', code: exitCode, signal })); } catch {} }
3146
- });
3147
- try { ws.send(JSON.stringify({ type: 'cwd', path: workingDir })); } catch {}
3148
- }
3217
+ ptyClients.add(ws);
3149
3218
 
3150
3219
  ws.on('message', (raw, isBinary) => {
3151
3220
  const str = raw.toString('utf8');
3152
3221
  if (!isBinary && str.startsWith('{')) {
3153
3222
  try {
3154
3223
  const m = JSON.parse(str);
3155
- if (m.type === 'init') { if (!initialized) { initialized = true; start(m.cols, m.rows); } return; }
3156
- if (m.type === 'resize' && pty) { try { pty.resize(m.cols || 100, m.rows || 30); } catch {} return; }
3157
- if (m.type === 'restart') { initialized = true; start(100, 30); return; }
3224
+ if (m.type === 'init') {
3225
+ // Spawn pty if not running yet
3226
+ ensurePty(m.cols, m.rows);
3227
+ // Replay scrollback so the refreshed browser sees prior output
3228
+ if (ptyScrollback.length > 0) {
3229
+ try { ws.send(Buffer.from(ptyScrollback, 'utf8')); } catch {}
3230
+ }
3231
+ // Always send current cwd
3232
+ try { ws.send(JSON.stringify({ type: 'cwd', path: workingDir })); } catch {}
3233
+ return;
3234
+ }
3235
+ if (m.type === 'resize' && sharedPty) {
3236
+ ptyCols = m.cols || ptyCols; ptyRows = m.rows || ptyRows;
3237
+ try { sharedPty.resize(ptyCols, ptyRows); } catch {}
3238
+ return;
3239
+ }
3240
+ if (m.type === 'restart') {
3241
+ // Kill current pty and start fresh; clear scrollback
3242
+ if (sharedPty) { try { sharedPty.kill(); } catch {} sharedPty = null; }
3243
+ ptyScrollback = '';
3244
+ ensurePty(ptyCols, ptyRows);
3245
+ try { ws.send(JSON.stringify({ type: 'cwd', path: workingDir })); } catch {}
3246
+ return;
3247
+ }
3158
3248
  } catch {}
3159
3249
  }
3160
- if (pty) pty.write(str);
3250
+ if (sharedPty) sharedPty.write(str);
3161
3251
  });
3162
3252
 
3163
- ws.on('close', () => { if (pty) { try { pty.kill(); } catch {} pty = null; } });
3253
+ ws.on('close', () => {
3254
+ ptyClients.delete(ws);
3255
+ // Do NOT kill the pty — keep it alive for the next reconnect.
3256
+ dbg('pty client disconnected (' + ptyClients.size + ' remaining)');
3257
+ });
3164
3258
  });
3165
3259
 
3166
3260
  // ── FS watcher: broadcast to all /events clients ─────────────────
@@ -3437,5 +3531,11 @@ server.on('listening', () => {
3437
3531
 
3438
3532
  tryListen(PORT);
3439
3533
 
3440
- process.on('SIGINT', () => { console.log('\nShutting down…'); try { watcher && watcher.close(); } catch {} process.exit(0); });
3441
- process.on('SIGTERM', () => process.exit(0));
3534
+ function shutdown() {
3535
+ console.log('\nShutting down…');
3536
+ try { watcher && watcher.close(); } catch {}
3537
+ if (sharedPty) { try { sharedPty.kill(); } catch {} }
3538
+ process.exit(0);
3539
+ }
3540
+ process.on('SIGINT', shutdown);
3541
+ process.on('SIGTERM', shutdown);