dsh-ssh-tui 0.8.2-rc.1 → 0.8.3

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.
package/lib/tui.js CHANGED
@@ -451,6 +451,81 @@ const QUOTA_RETRY_MS = 15_000;
451
451
  const SETUP_STEPS = ['provider', 'id', 'base-url', 'key', 'models', 'models-pick', 'model-default', 'context', 'confirm'];
452
452
  /** The steps that show a text field (the others are pickers or the confirmation). */
453
453
  const SETUP_FIELD_STEPS = new Set(['provider', 'id', 'base-url', 'key', 'models', 'context']);
454
+ /**
455
+ * Whether one data chunk is a paste rather than keystrokes.
456
+ *
457
+ * Bracketed paste says so outright, and that is the path every modern terminal
458
+ * takes. A console without it sends the content as one burst instead, and a burst
459
+ * carrying more than one line break is not a human pressing Enter twice — they
460
+ * cannot press it twice inside one read. This is the fallback's one rule, kept
461
+ * here so every surface that can take a paste applies the same one.
462
+ */
463
+ function looksLikePasteBurst(text) {
464
+ let breaks = 0;
465
+ for (let index = 0; index < text.length; index += 1) {
466
+ const char = text[index];
467
+ if (char === '\r' && text[index + 1] !== '\n')
468
+ breaks += 1;
469
+ else if (char === '\n' && text[index - 1] !== '\r')
470
+ breaks += 1;
471
+ if (breaks > 1)
472
+ return true;
473
+ }
474
+ return false;
475
+ }
476
+ /**
477
+ * Split one read into the keys it carries.
478
+ *
479
+ * A terminal hands over whatever arrived together, which is not always one key: a
480
+ * fast typist on a slow link sends `text\r`, a console without bracketed paste sends
481
+ * a whole line, and a paste is followed by Enter inside the same read. The composer
482
+ * has always taken a read apart (`handlePlainText` types it character by character),
483
+ * and a Screen has to see the same keys — a control byte is only itself when it
484
+ * stands alone, so a read handed to one step is a read whose Enter is dropped on the
485
+ * floor. That is what this returns: escape sequences whole (an arrow is one key),
486
+ * control bytes one each with CRLF read as the one Enter it is, and a run of
487
+ * printable text kept together, because a step that filters on what it is given
488
+ * wants the run and a paste without markers still must not answer a confirmation.
489
+ */
490
+ function screenKeys(text) {
491
+ const keys = [];
492
+ let index = 0;
493
+ while (index < text.length) {
494
+ const char = text[index];
495
+ if (char === '\x1b') {
496
+ // A CSI sequence (`\x1b[` params final), SS3 (`\x1bO` one char), or the lone
497
+ // Escape key itself when nothing follows it in this read.
498
+ const rest = text.slice(index);
499
+ const sequence = /^\x1b\[[0-9;?<]*[ -/]*[@-~]/u.exec(rest)?.[0] ?? /^\x1bO[@-~]/u.exec(rest)?.[0];
500
+ if (sequence !== undefined) {
501
+ keys.push(sequence);
502
+ index += sequence.length;
503
+ continue;
504
+ }
505
+ keys.push('\x1b');
506
+ index += 1;
507
+ continue;
508
+ }
509
+ if (char === '\r' || char === '\n') {
510
+ // Windows terminals send Enter as CRLF; both halves are one key.
511
+ const next = text[index + 1];
512
+ keys.push('\r');
513
+ index += (char === '\r' && next === '\n') || (char === '\n' && next === '\r') ? 2 : 1;
514
+ continue;
515
+ }
516
+ if (char < ' ' || char === '\x7f') {
517
+ keys.push(char);
518
+ index += 1;
519
+ continue;
520
+ }
521
+ let end = index;
522
+ while (end < text.length && text[end] >= ' ' && text[end] !== '\x7f')
523
+ end += 1;
524
+ keys.push(text.slice(index, end));
525
+ index = end;
526
+ }
527
+ return keys;
528
+ }
454
529
  /**
455
530
  * The durable event types the plan projection reads (B2.5).
456
531
  *
@@ -11271,6 +11346,41 @@ export class SshTui {
11271
11346
  const screen = this.screen;
11272
11347
  if (screen === undefined)
11273
11348
  return;
11349
+ // Ctrl+L repaints the frame and belongs to the terminal rather than to any
11350
+ // surface that happens to be up: a Screen replaces the workspace entirely, so a
11351
+ // reader with a garbled terminal would otherwise have to leave the Screen to
11352
+ // clear it. The next paint is a full one, exactly as the composer's Ctrl+L is.
11353
+ if (combined === '\x0c') {
11354
+ this.screenNeedsFullPaint = true;
11355
+ this.markDirty();
11356
+ return;
11357
+ }
11358
+ // A burst of line breaks in one read is a paste from a console that cannot say so
11359
+ // (`looksLikePasteBurst`): it belongs in the field as one edit, which is how a
11360
+ // pasted key or model list survives a terminal without bracketed paste. It is
11361
+ // asked *before* the split below, because splitting is what would turn one paste
11362
+ // into a step's worth of Enters.
11363
+ if (looksLikePasteBurst(combined)) {
11364
+ this.pasteIntoScreen(combined);
11365
+ return;
11366
+ }
11367
+ // A read is not a key (`screenKeys`). This is the only path a Screen's input
11368
+ // arrives on, so a read carrying several keys — `text\r` from a fast typist on a
11369
+ // slow link, an arrow batched with the key after it — has to be taken apart here
11370
+ // or the keys after the first are spent on a step that cannot read them. Each key
11371
+ // is handed on its own, and the rest of the read is dropped once the Screen it was
11372
+ // addressed to is gone: the keys after a dismissal belong to whatever comes next,
11373
+ // and guessing that here is how a paste ended up in the composer in the first
11374
+ // place.
11375
+ const keys = screenKeys(combined);
11376
+ if (keys.length > 1) {
11377
+ for (const key of keys) {
11378
+ if (this.screen === undefined)
11379
+ return;
11380
+ this.handleScreenInput(key, this.keymap.sequences.get(key));
11381
+ }
11382
+ return;
11383
+ }
11274
11384
  // The setup Screen owns its own field, its own navigation and its own keys: it
11275
11385
  // consumes everything while it is up, and nothing reaches the workspace's
11276
11386
  // composer, history or transcript (B2.6 §4).
@@ -11683,14 +11793,23 @@ export class SshTui {
11683
11793
  * A paste is text input, so it goes through the same ownership question ordinary
11684
11794
  * typing does: into the composer's draft when the composer owns the keyboard, and
11685
11795
  * into a borrowed field when a surface that has one does. A surface without a
11686
- * text field (a confirmation, the inspect overlay) takes no paste at all — the
11796
+ * text field (a confirmation, a menu, a report) takes no paste at all — the
11687
11797
  * alternative was writing the composer's hidden draft behind it, which is how a
11688
11798
  * paste into an approval dialog could silently rewrite the message being written.
11799
+ *
11800
+ * A **Screen** is that same question with a different answer, and it is asked
11801
+ * first because `textIsEditable()` only ever answers for the composer's field: the
11802
+ * setup Screen keeps a field of its own, which the composer's helpers cannot see
11803
+ * and must never be handed (B2.6 §4).
11689
11804
  */
11690
11805
  handlePasteText(text) {
11691
11806
  const normalized = text.replaceAll('\r\n', '\n').replaceAll('\r', '\n');
11692
11807
  if (normalized === '')
11693
11808
  return;
11809
+ if (this.screen !== undefined) {
11810
+ this.pasteIntoScreen(normalized);
11811
+ return;
11812
+ }
11694
11813
  if (!this.textIsEditable())
11695
11814
  return;
11696
11815
  this.leaveHistoryBrowse();
@@ -11705,29 +11824,52 @@ export class SshTui {
11705
11824
  }
11706
11825
  this.markDirty();
11707
11826
  }
11827
+ /**
11828
+ * Give a paste to the field the Screen owns, or to nobody.
11829
+ *
11830
+ * The setup Screen is the only Screen with a field, and it is the one place a
11831
+ * paste matters most: an API key, a base URL and a model list are all copied from
11832
+ * somewhere else, and a first run that could only be typed by hand is the
11833
+ * difference between a working session and an empty one. A step without a field —
11834
+ * the model pickers, the confirmation — is not a text field, so the paste is
11835
+ * dropped exactly as a menu drops it: a paste is text, never an answer.
11836
+ */
11837
+ pasteIntoScreen(text) {
11838
+ const state = this.onboarding;
11839
+ if (this.screen?.kind !== 'setup' || state === undefined || state.saving)
11840
+ return;
11841
+ if (!SETUP_FIELD_STEPS.has(state.step))
11842
+ return;
11843
+ // The field is drawn on one row, so it takes the value and not the line breaks
11844
+ // around it: a key copied off a web page carries the newline that ended its
11845
+ // line, and a pasted list or `.env` block means the breaks as separators. Both
11846
+ // are folded here rather than inserted literally, which would leave the field
11847
+ // holding text no step can parse and no row can draw.
11848
+ const flat = text.split('\n').map(part => part.trim()).filter(part => part !== '').join(' ');
11849
+ if (flat === '')
11850
+ return;
11851
+ this.setupInsert(state, flat);
11852
+ this.markDirty();
11853
+ }
11708
11854
  /**
11709
11855
  * Whether the keys that edit text belong to a field right now.
11710
11856
  *
11711
11857
  * The composer always has one; an open surface only has one when it borrowed it
11712
- * (a free-text answer, a setup field). Everything else — a confirmation, a menu,
11713
- * the inspect overlay — owns the keyboard without owning text, and those keys
11714
- * must do nothing rather than reach the draft behind the surface.
11858
+ * (a free-text answer). Everything else — a confirmation, a menu, the inspect
11859
+ * overlay, every Screen — owns the keyboard without owning the composer's text,
11860
+ * and those keys must do nothing rather than reach the draft behind the surface.
11861
+ * A Screen is included by name: the setup Screen has a field of its own, and it
11862
+ * is reached through the Screen's own keys, never through the composer's.
11715
11863
  */
11716
11864
  textIsEditable() {
11865
+ if (this.screen !== undefined)
11866
+ return false;
11717
11867
  return this.dialog === undefined || this.borrowedText !== undefined;
11718
11868
  }
11719
11869
  handlePlainText(text) {
11720
11870
  // Fallback for terminals without bracketed paste: a burst of multiple line
11721
11871
  // breaks in one chunk is a paste, not repeated Enter presses.
11722
- let newlines = 0;
11723
- for (let index = 0; index < text.length; index += 1) {
11724
- const char = text[index];
11725
- if (char === '\r' && text[index + 1] !== '\n')
11726
- newlines += 1;
11727
- else if (char === '\n' && text[index - 1] !== '\r')
11728
- newlines += 1;
11729
- }
11730
- if (newlines > 1) {
11872
+ if (looksLikePasteBurst(text)) {
11731
11873
  this.handlePasteText(text);
11732
11874
  return;
11733
11875
  }
@@ -11747,11 +11889,15 @@ export class SshTui {
11747
11889
  }
11748
11890
  }
11749
11891
  handleChar(char) {
11750
- // A setup Screen owns every key, Enter included: it is the key that submits a
11751
- // step, and the composer behind the screen must never receive one (B2.6 §4).
11752
- // Ctrl+C is the exception — it is the terminal's own interrupt and keeps its
11753
- // global meaning.
11754
- if (this.screen?.kind === 'setup' && char !== '\x03') {
11892
+ // A Screen owns every key outright, and the gate comes first for the same reason
11893
+ // `handleInputText` puts it first: the switch below spends its keys on the
11894
+ // composer — Enter submits the draft, Backspace/Ctrl+U/Ctrl+K edit it, Ctrl+R
11895
+ // folds the transcript behind the Screen. A Screen is not a dialog, so none of
11896
+ // the `dialog === undefined` guards in that switch ever saw it, and every one of
11897
+ // those keys used to reach a draft the reader could not see. The Screen decides
11898
+ // what it uses (Enter and Ctrl+C close it, Esc leaves it, the wizard answers with
11899
+ // them); anything it does not use is dropped, never forwarded (B2.6 §4).
11900
+ if (this.screen !== undefined) {
11755
11901
  this.handleScreenInput(char, this.keymap.sequences.get(char));
11756
11902
  return;
11757
11903
  }
@@ -12249,13 +12395,6 @@ export class SshTui {
12249
12395
  this.markDirty();
12250
12396
  return;
12251
12397
  }
12252
- if (combined === '\x0c') {
12253
- // Ctrl+L still repaints the whole frame: the field is a row like any other,
12254
- // and a forced repaint is how a reader clears a garbled terminal.
12255
- this.screenNeedsFullPaint = true;
12256
- this.markDirty();
12257
- return;
12258
- }
12259
12398
  if (combined === '\x15') {
12260
12399
  // Ctrl+U clears the wizard's field, not the workspace's draft.
12261
12400
  this.setupSetField(state, '', 0);
@@ -12725,7 +12864,7 @@ export class SshTui {
12725
12864
  this.setupMessage(state, 'system', t('onboard.baseSaved', { path: displayDshPath('settings.yaml') }));
12726
12865
  }
12727
12866
  if (saved) {
12728
- this.setupMessage(state, 'system', t('onboard.officialDone', { model }));
12867
+ this.onboardDone(t('onboard.officialDone', { model }));
12729
12868
  }
12730
12869
  }
12731
12870
  else {
@@ -12898,7 +13037,7 @@ export class SshTui {
12898
13037
  this.onSelectionChanged?.(selection);
12899
13038
  await this.rememberRoute(selection);
12900
13039
  await this.syncSubagentToProvider(ownerId, state.models, previousParentProvider !== ownerId);
12901
- this.setupMessage(state, 'system', t('onboard.customDone', { id: ownerId, model }));
13040
+ this.onboardDone(t('onboard.customDone', { id: ownerId, model }));
12902
13041
  }
12903
13042
  }
12904
13043
  }
@@ -12907,12 +13046,45 @@ export class SshTui {
12907
13046
  this.setupMessage(state, 'error', t('onboard.saveFailed', { error: errorChain(error) }));
12908
13047
  }
12909
13048
  finally {
12910
- this.onboarding = undefined;
12911
13049
  state.resolve(saved);
13050
+ // The Screen's exit is the answer, and the answer is one of two things.
13051
+ //
13052
+ // **Saved**: the wizard has nothing left to ask, so it hands the workspace back
13053
+ // in the same frame. Cancelling always did this; saving did not, so a completed
13054
+ // setup left the reader on the wizard's own frame until they pressed a key —
13055
+ // and only that key's "no wizard behind this Screen" fallback closed it. Every
13056
+ // confirmation the wizard would have painted on its own row went with the
13057
+ // Screen, which is why the completion line is a transcript row (`onboardDone`).
13058
+ //
13059
+ // **Failed**: the wizard keeps the screen, because the row it just wrote is the
13060
+ // reason and the answer (an API key, a Base URL) has to stay editable to retry.
13061
+ // Dropping the state here is what used to leave a Screen with no wizard behind
13062
+ // it: the error was written and then thrown away with it, and the next keypress
13063
+ // dismissed the whole flow as if nothing had been asked.
13064
+ if (saved) {
13065
+ this.onboarding = undefined;
13066
+ this.closeScreen();
13067
+ }
13068
+ else {
13069
+ state.saving = false;
13070
+ }
12912
13071
  this.showNextDialog();
12913
13072
  this.markDirty();
12914
13073
  }
12915
13074
  }
13075
+ /**
13076
+ * Say the wizard is done, where the reader will still be able to read it.
13077
+ *
13078
+ * A saved wizard closes its Screen in the same frame, so its own notice row would
13079
+ * be painted for no frames at all — and the credential line that goes with it is a
13080
+ * footer echo (`command-feedback`), which expires. This is the durable half of the
13081
+ * answer: `command-status` routes to the transcript, exactly like the boot line
13082
+ * that asked for the setup in the first place.
13083
+ */
13084
+ onboardDone(text) {
13085
+ this.pushRow(represent('command-status', { kind: 'system', text }));
13086
+ this.markDirty();
13087
+ }
12916
13088
  /** Store one credential, falling back to a launch-environment override on shadow/absence. */
12917
13089
  async saveCredential(credentials, envRef, key) {
12918
13090
  const shadowing = process.env[envRef];