agent-sh 0.12.26 → 0.12.27

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.
@@ -90,7 +90,8 @@ export class FloatingPanel {
90
90
  * - `{prefix}:render-border-bottom(ctx: FrameContext) -> string`
91
91
  * - `{prefix}:composite-row(content: string, bgLine: string|null, boxLeft: number, boxW: number, cols: number) -> string`
92
92
  * - `{prefix}:submit(query: string) -> void`
93
- * - `{prefix}:dismiss() -> void`
93
+ * - `{prefix}:hide() -> void` (screen down; conversation state preserved)
94
+ * - `{prefix}:reset() -> void` (conversation state cleared)
94
95
  * - `{prefix}:show() -> void`
95
96
  * - `{prefix}:input(data: string) -> boolean`
96
97
  * - `{prefix}:build-row(content: string, width: number) -> string`
@@ -123,6 +124,10 @@ export class FloatingPanel {
123
124
  wrapCacheWidth = 0;
124
125
  passthroughTimer = null;
125
126
  prevSerialized = "";
127
+ // ── Autocomplete ────────────────────────────────────────────
128
+ autocompleteItems = [];
129
+ autocompleteIndex = 0;
130
+ autocompleteActive = false;
126
131
  constructor(bus, config, handlers) {
127
132
  this.bus = bus;
128
133
  this.surface = config.surface ?? new StdoutSurface();
@@ -166,10 +171,54 @@ export class FloatingPanel {
166
171
  }
167
172
  all.push(...wrapped);
168
173
  }
169
- // In input phase, append the prompt line at the bottom of content
174
+ if (ctx.phase === "input" && this.autocompleteActive && this.autocompleteItems.length > 0) {
175
+ const ACMax = 5;
176
+ const items = this.autocompleteItems;
177
+ let acStart = 0;
178
+ let acEnd = items.length;
179
+ if (items.length > ACMax) {
180
+ acStart = Math.max(0, this.autocompleteIndex - Math.floor(ACMax / 2));
181
+ acStart = Math.min(acStart, items.length - ACMax);
182
+ acEnd = acStart + ACMax;
183
+ }
184
+ for (let i = acStart; i < acEnd; i++) {
185
+ const item = items[i];
186
+ const selected = i === this.autocompleteIndex;
187
+ const desc = item.description ? ` ${item.description}` : "";
188
+ if (selected) {
189
+ all.push(`${INVERSE} ${item.name}${desc} ${RESET}`);
190
+ }
191
+ else {
192
+ all.push(` ${item.name}${DIM}${desc}${RESET}`);
193
+ }
194
+ }
195
+ }
196
+ let promptStartIdx = -1;
197
+ let cursorRowOffset = 0;
198
+ let cursorCol = 0;
170
199
  if (ctx.phase === "input") {
171
- const promptLine = `\x1b[36m${this.config.promptIcon}${RESET} ${ctx.inputBuffer}`;
172
- all.push(promptLine);
200
+ const w = ctx.width;
201
+ const prefixLen = this.config.promptIcon.length + 1;
202
+ const styledPrefix = `\x1b[36m${this.config.promptIcon}${RESET} `;
203
+ const input = ctx.inputBuffer;
204
+ const firstLineCap = Math.max(1, w - prefixLen);
205
+ promptStartIdx = all.length;
206
+ if (input.length === 0) {
207
+ all.push(styledPrefix);
208
+ }
209
+ else {
210
+ all.push(styledPrefix + input.slice(0, firstLineCap));
211
+ for (let i = firstLineCap; i < input.length; i += w) {
212
+ all.push(input.slice(i, i + w));
213
+ }
214
+ }
215
+ const cursorVp = prefixLen + ctx.inputCursor;
216
+ cursorRowOffset = Math.floor(cursorVp / w);
217
+ cursorCol = cursorVp % w;
218
+ // Cursor on an exact wrap boundary lands past the last rendered row.
219
+ while (all.length - promptStartIdx <= cursorRowOffset) {
220
+ all.push("");
221
+ }
173
222
  }
174
223
  // Scroll: auto-scroll to bottom unless user manually scrolled
175
224
  let offset = ctx.scrollOffset;
@@ -185,24 +234,20 @@ export class FloatingPanel {
185
234
  }
186
235
  this.scrollOffset = offset;
187
236
  const visible = all.slice(offset, offset + ctx.height);
188
- // Cursor position for input mode
189
- if (ctx.phase === "input") {
190
- const promptRow = visible.length - 1;
191
- // If prompt is visible, set cursor
192
- if (promptRow >= 0) {
237
+ if (ctx.phase === "input" && promptStartIdx >= 0) {
238
+ const cursorRowInVisible = promptStartIdx + cursorRowOffset - offset;
239
+ if (cursorRowInVisible >= 0 && cursorRowInVisible < visible.length) {
193
240
  return {
194
241
  lines: visible,
195
- cursor: { row: promptRow, col: this.config.promptIcon.length + 1 + ctx.inputCursor },
242
+ cursor: { row: cursorRowInVisible, col: cursorCol },
196
243
  };
197
244
  }
198
245
  }
199
246
  return { lines: visible };
200
247
  });
201
- // Default submit: no-op (extension overrides)
202
248
  this.handlers.define(`${p}:submit`, (_query) => { });
203
- // Default dismiss: no-op
204
- this.handlers.define(`${p}:dismiss`, () => { });
205
- // Default show: no-op (extension overrides to rebuild content on re-show)
249
+ this.handlers.define(`${p}:hide`, () => { });
250
+ this.handlers.define(`${p}:reset`, () => { });
206
251
  this.handlers.define(`${p}:show`, () => { });
207
252
  // Default custom input handler: don't consume
208
253
  this.handlers.define(`${p}:input`, (_data) => false);
@@ -342,6 +387,7 @@ export class FloatingPanel {
342
387
  this.ensureBuffer();
343
388
  this.phase = "input";
344
389
  this.editor.clear();
390
+ this.clearAutocomplete();
345
391
  this.contentLines = [];
346
392
  this.currentPartialLine = "";
347
393
  this.scrollOffset = 0;
@@ -373,7 +419,7 @@ export class FloatingPanel {
373
419
  // Agent idle or done — full teardown, hand back control.
374
420
  this.teardownScreen();
375
421
  }
376
- this.handlers.call(`${this.prefix}:dismiss`);
422
+ this.handlers.call(`${this.prefix}:hide`);
377
423
  }
378
424
  /** Show the panel again after hide(), preserving conversation. */
379
425
  show() {
@@ -394,14 +440,27 @@ export class FloatingPanel {
394
440
  }
395
441
  this.handlers.call(`${this.prefix}:show`);
396
442
  }
397
- /** Fully destroy the panel, resetting all state. */
398
- dismiss() {
443
+ /** End the conversation: screen down and all buffered state cleared. */
444
+ reset() {
399
445
  if (this.phase === "idle")
400
446
  return;
401
447
  if (this.autoDismissTimer) {
402
448
  clearTimeout(this.autoDismissTimer);
403
449
  this.autoDismissTimer = null;
404
450
  }
451
+ this.teardownToHidden();
452
+ this.phase = "idle";
453
+ this.editor.clear();
454
+ this.clearAutocomplete();
455
+ this.contentLines = [];
456
+ this.currentPartialLine = "";
457
+ this.scrollOffset = 0;
458
+ this.title = "";
459
+ this.footer = "";
460
+ this.handlers.call(`${this.prefix}:reset`);
461
+ }
462
+ /** Screen-only teardown; conversation state is left untouched. */
463
+ teardownToHidden() {
405
464
  if (this._passthrough) {
406
465
  this.stopPassthrough();
407
466
  this._passthrough = false;
@@ -416,13 +475,6 @@ export class FloatingPanel {
416
475
  this.prevFrame = [];
417
476
  this.teardownScreen();
418
477
  }
419
- this.phase = "idle";
420
- this.editor.clear();
421
- this.contentLines = [];
422
- this.currentPartialLine = "";
423
- this.scrollOffset = 0;
424
- this.title = "";
425
- this.footer = "";
426
478
  }
427
479
  /** Common screen enter logic shared by open() and show(). */
428
480
  enterScreen() {
@@ -489,24 +541,25 @@ export class FloatingPanel {
489
541
  this.phase = "active";
490
542
  }
491
543
  setDone() {
492
- if (this._passthrough) {
493
- // Agent finished while hidden — session over, hand back control.
494
- this.dismiss();
495
- return;
496
- }
497
544
  if (this.config.autoDismissMs > 0) {
498
- // Legacy behavior: enter done state, auto-dismiss after delay
499
545
  this.phase = "done";
500
- this.render();
501
546
  this.autoDismissTimer = setTimeout(() => {
502
547
  if (this.phase === "done")
503
- this.dismiss();
548
+ this.reset();
504
549
  }, this.config.autoDismissMs);
505
550
  }
506
551
  else {
507
- // Auto-prompt: transition to input for follow-up conversation
508
552
  this.phase = "input";
509
553
  this.editor.clear();
554
+ this.clearAutocomplete();
555
+ }
556
+ if (this._passthrough) {
557
+ // Agent finished while hidden — release the screen but keep state
558
+ // so the next summon resumes the transcript.
559
+ this.teardownToHidden();
560
+ this.handlers.call(`${this.prefix}:hide`);
561
+ }
562
+ else {
510
563
  this.render();
511
564
  }
512
565
  }
@@ -526,6 +579,64 @@ export class FloatingPanel {
526
579
  requestRender() {
527
580
  this.scheduleRender();
528
581
  }
582
+ // ── Autocomplete helpers ────────────────────────────────────
583
+ updateAutocomplete() {
584
+ if (this.phase !== "input") {
585
+ this.clearAutocomplete();
586
+ return;
587
+ }
588
+ const buf = this.editor.text;
589
+ let command = null;
590
+ let commandArgs = null;
591
+ if (buf.startsWith("/")) {
592
+ const spaceIdx = buf.indexOf(" ");
593
+ if (spaceIdx !== -1) {
594
+ command = buf.slice(0, spaceIdx);
595
+ commandArgs = buf.slice(spaceIdx + 1);
596
+ }
597
+ }
598
+ const { items } = this.bus.emitPipe("autocomplete:request", {
599
+ buffer: buf,
600
+ command,
601
+ commandArgs,
602
+ items: [],
603
+ });
604
+ if (items.length > 0) {
605
+ this.autocompleteItems = items;
606
+ this.autocompleteActive = true;
607
+ if (this.autocompleteIndex >= items.length)
608
+ this.autocompleteIndex = 0;
609
+ }
610
+ else {
611
+ this.clearAutocomplete();
612
+ }
613
+ }
614
+ applyAutocomplete() {
615
+ if (!this.autocompleteActive || this.autocompleteItems.length === 0)
616
+ return false;
617
+ const sel = this.autocompleteItems[this.autocompleteIndex];
618
+ if (!sel)
619
+ return false;
620
+ // For @file completion only the partial after the last @ is replaced.
621
+ const text = this.editor.text;
622
+ const atPos = text.lastIndexOf("@");
623
+ const isFileAc = atPos >= 0
624
+ && (atPos === 0 || text[atPos - 1] === " ")
625
+ && !text.slice(atPos + 1).includes(" ");
626
+ if (isFileAc) {
627
+ this.editor.setText(text.slice(0, atPos) + "@" + sel.name);
628
+ }
629
+ else {
630
+ this.editor.setText(sel.name);
631
+ }
632
+ this.clearAutocomplete();
633
+ return true;
634
+ }
635
+ clearAutocomplete() {
636
+ this.autocompleteActive = false;
637
+ this.autocompleteItems = [];
638
+ this.autocompleteIndex = 0;
639
+ }
529
640
  // ── Input handling ──────────────────────────────────────────
530
641
  handleIntercept(payload) {
531
642
  const consumed = { ...payload, consumed: true };
@@ -541,7 +652,7 @@ export class FloatingPanel {
541
652
  }
542
653
  switch (this.phase) {
543
654
  case "done":
544
- this.dismiss();
655
+ this.reset();
545
656
  return consumed;
546
657
  case "input":
547
658
  this.handleInputKey(data);
@@ -568,41 +679,40 @@ export class FloatingPanel {
568
679
  return payload;
569
680
  }
570
681
  }
571
- /** Handle scroll input. Returns true if consumed. */
572
- handleScroll(data) {
573
- // Arrow up / mouse wheel up
574
- if (data === "\x1b[A" || data === "\x1bOA") {
575
- this.scrollUp(1);
576
- return true;
577
- }
578
- // Arrow down / mouse wheel down
579
- if (data === "\x1b[B" || data === "\x1bOB") {
580
- this.scrollDown(1);
581
- return true;
682
+ /**
683
+ * Handle scroll input. Returns true if consumed.
684
+ * Pass `includeArrows=false` in input phase so arrows reach the editor.
685
+ */
686
+ handleScroll(data, includeArrows = true) {
687
+ if (includeArrows) {
688
+ if (data === "\x1b[A" || data === "\x1bOA") {
689
+ this.scrollUp(1);
690
+ return true;
691
+ }
692
+ if (data === "\x1b[B" || data === "\x1bOB") {
693
+ this.scrollDown(1);
694
+ return true;
695
+ }
582
696
  }
583
- // Page up (CSI 5~)
584
697
  if (data === "\x1b[5~") {
585
698
  this.scrollUp(this.computeGeometry().contentH - 1);
586
699
  return true;
587
700
  }
588
- // Page down (CSI 6~)
589
701
  if (data === "\x1b[6~") {
590
702
  this.scrollDown(this.computeGeometry().contentH - 1);
591
703
  return true;
592
704
  }
593
- // Mouse wheel: CSI M followed by button byte (64 = wheel up, 65 = wheel down)
594
705
  if (data.length >= 6 && data.startsWith("\x1b[M")) {
595
706
  const button = data.charCodeAt(3);
596
707
  if (button === 96) {
597
708
  this.scrollUp(3);
598
709
  return true;
599
- } // wheel up
710
+ }
600
711
  if (button === 97) {
601
712
  this.scrollDown(3);
602
713
  return true;
603
- } // wheel down
714
+ }
604
715
  }
605
- // SGR mouse: CSI < 64;x;yM (wheel up) / CSI < 65;x;yM (wheel down)
606
716
  const sgr = data.match(/^\x1b\[<(64|65);\d+;\d+M$/);
607
717
  if (sgr) {
608
718
  if (sgr[1] === "64") {
@@ -617,59 +727,92 @@ export class FloatingPanel {
617
727
  return false;
618
728
  }
619
729
  handleInputKey(data) {
620
- // Check full data string against trigger sequences (may be multi-byte)
621
730
  if (this.isTrigger(data)) {
622
731
  this.hide();
623
732
  return;
624
733
  }
625
734
  for (let i = 0; i < data.length; i++) {
626
735
  const ch = data[i];
627
- if (ch === "\x1b" && data[i + 1] == null) {
628
- this.hide();
629
- return;
630
- }
631
- if (ch.charCodeAt(0) === 0x03) {
736
+ if ((ch === "\x1b" && data[i + 1] == null) || ch.charCodeAt(0) === 0x03) {
737
+ // First Esc/Ctrl+C closes the dropdown; second hides the panel.
738
+ if (this.autocompleteActive) {
739
+ this.clearAutocomplete();
740
+ this.render();
741
+ return;
742
+ }
632
743
  this.hide();
633
744
  return;
634
745
  }
635
746
  }
636
- // Page Up/Down and mouse wheel scroll even in input phase
637
- if (this.handleScroll(data))
747
+ if (this.handleScroll(data, false))
638
748
  return;
639
749
  const actions = this.editor.feed(data);
640
750
  for (const action of actions) {
641
751
  switch (action.action) {
642
752
  case "submit": {
753
+ // Apply selection on Enter so it both picks and submits.
754
+ this.applyAutocomplete();
643
755
  const query = this.editor.text.trim();
644
756
  if (!query) {
645
757
  this.hide();
646
758
  return;
647
759
  }
648
760
  this.editor.pushHistory(query);
649
- this.phase = "active";
650
761
  this.editor.clear();
762
+ this.clearAutocomplete();
763
+ // Phase change is the submit handler's call — sync slash commands
764
+ // (e.g. /model, /help) keep the user in input mode.
651
765
  this.handlers.call(`${this.prefix}:submit`, query);
652
766
  return;
653
767
  }
654
768
  case "cancel":
769
+ if (this.autocompleteActive) {
770
+ this.clearAutocomplete();
771
+ this.render();
772
+ return;
773
+ }
655
774
  this.hide();
656
775
  return;
776
+ case "tab":
777
+ // Re-query after applying a command name so arg completions show.
778
+ if (this.applyAutocomplete())
779
+ this.updateAutocomplete();
780
+ this.render();
781
+ break;
782
+ case "shift+tab":
783
+ this.render();
784
+ break;
657
785
  case "arrow-up": {
658
- const hist = this.editor.historyBack();
659
- if (hist)
786
+ if (this.autocompleteActive) {
787
+ this.autocompleteIndex = this.autocompleteIndex === 0
788
+ ? this.autocompleteItems.length - 1
789
+ : this.autocompleteIndex - 1;
660
790
  this.render();
791
+ }
792
+ else {
793
+ const hist = this.editor.historyBack();
794
+ if (hist)
795
+ this.render();
796
+ }
661
797
  break;
662
798
  }
663
799
  case "arrow-down": {
664
- const hist = this.editor.historyForward();
665
- if (hist)
800
+ if (this.autocompleteActive) {
801
+ this.autocompleteIndex = this.autocompleteIndex === this.autocompleteItems.length - 1
802
+ ? 0
803
+ : this.autocompleteIndex + 1;
666
804
  this.render();
805
+ }
806
+ else {
807
+ const hist = this.editor.historyForward();
808
+ if (hist)
809
+ this.render();
810
+ }
667
811
  break;
668
812
  }
669
813
  case "changed":
670
- case "tab":
671
- case "shift+tab":
672
814
  case "delete-empty":
815
+ this.updateAutocomplete();
673
816
  this.render();
674
817
  break;
675
818
  }