dsh-ssh-tui 0.8.3 → 0.8.4-rc.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.
package/lib/tui.js CHANGED
@@ -21,13 +21,14 @@ import { mkdir, readFile, writeFile } from 'node:fs/promises';
21
21
  import { homedir } from 'node:os';
22
22
  import { dirname, join } from 'node:path';
23
23
  import { createRequire } from 'node:module';
24
- import { StringDecoder } from 'node:string_decoder';
25
24
  import { METADATA_FILE, readPresetMetadata } from './preset-compat.js';
26
25
  import { credentialRef } from '@deepseek-ai/dsh-credentials';
27
26
  import { createUserMessage, errorChain, ReasoningEffortId } from '@deepseek-ai/dsh-llm';
28
27
  import { SessionId } from '@deepseek-ai/dsh-session';
29
- import { commandAcceptsAttachments, forEachSessionEventAsync, hostSettingsGeneration, isTokenDeltaChunk, listenHostEvent, readSettingsSection, REPLAY_YIELD_EVERY, sessionEvents, settingsNamespace, streamChunkOf, streamFirstTokenTime, streamFrameAttemptId, streamFrameOwner, toolResultFailed, TUI_SOURCE_KIND, } from './dsh-compat.js';
28
+ import { commandAcceptsAttachments, forEachSessionEventAsync, hostSettingsGeneration, isTokenDeltaChunk, listenHostEvent, drainChildSubagents, readSettingsSection, REPLAY_YIELD_EVERY, sessionEvents, settingsNamespace, streamChunkOf, streamFirstTokenTime, streamFrameAttemptId, streamFrameOwner, toolResultFailed, TUI_SOURCE_KIND, } from './dsh-compat.js';
30
29
  import { classifyApprovalDetailed, commandForApprovalRequest, isApprovalStatusArg, parseAutoApprovalMode } from './auto-approval.js';
30
+ import { InputReader } from './input-reader.js';
31
+ import { InputRouter } from './input-router.js';
31
32
  import { auditQuestionPrimaries, auditRepresentations, formatRepresentationAudit, isRepresentation, represent, } from './representation.js';
32
33
  import { clampScreenOffset, reportRowKind, screenFromDialog, screenLayout, screenPositionText, } from './screen.js';
33
34
  import { screenRuntimeStrip } from './footer-budget.js';
@@ -74,8 +75,7 @@ import { UserQuestionError, } from '@deepseek-ai/dsh-user-questions';
74
75
  import { durableQuestionRecords, answerSummaryText, questionViewOf, } from './question-state.js';
75
76
  import { clipAnsiToWidth, cursorVisualPosition, displayWidth, fmtElapsedCompact, foldInputView, highlightAnsiNeedle, hrefAtColumn, lastCodePoints, osc52Clipboard, padToWidth, paintSegmentedLine, paintedLinkHits, renderMarkdownLines, repeatToWidth, sanitizeTerminalText, searchContains, setAmbiguousWidthMeasured, setAmbiguousWidthReserve, shimmerText, sliceCodePoints, stripAnsi, truncate, truncateToWidth, visibleWidth, waitCardCopy, wrap, wrapSegmented, wrapWaitDetails, } from './term-text.js';
76
77
  import { clampSelection, offsetAfterColumn, offsetAtColumn, selectionSpans, selectionText, } from './selection.js';
77
- import { captureHangupSignals, advancePaintedRows, composePaintFrame, frameByteBudget, detectSshSession, formatLinkQualityChip, HANGUP_CANCEL_TIMEOUT_MS, ignoreFurtherHangupSignals, isEscapePrefix, isHangupErrno, linkQualityOf, linkRedrawBudgetMs, parseCursorPositionReply, PICKER_WINDOW, pickerWindowStart, probeTerminalRttMs, releaseHangupSignals, resolvePaintIntervalMs, toolBodyLineLimit, waitUntilIdleOrTimeout, RTT_HISTORY, medianRtt, } from './paint.js';
78
- import { TerminalInputGuard } from './terminal-input.js';
78
+ import { captureHangupSignals, advancePaintedRows, composePaintFrame, frameByteBudget, detectSshSession, formatLinkQualityChip, HANGUP_CANCEL_TIMEOUT_MS, ignoreFurtherHangupSignals, isHangupErrno, linkQualityOf, linkRedrawBudgetMs, PICKER_WINDOW, pickerWindowStart, probeTerminalRttMs, releaseHangupSignals, resolvePaintIntervalMs, toolBodyLineLimit, waitUntilIdleOrTimeout, RTT_HISTORY, medianRtt, } from './paint.js';
79
79
  import { contextPressureAlertText, contextPressureView, describeProviderRoute, fitFooterStatusLine, footerActivity, footerHealthChip, footerIdentityParts, footerSubagentForeign, paintFooterSubagentChip, footerStatsGroups, formatContextPressureChip, runtimeStrip, providerHasQuotaSurface, formatStatusReport, planRouteBadge, shortQuotaPlanName, formatTokens, parseContextPressure, promptPressureTokens, providerUsesLocalOAuth, shouldIdleAutoCompact, } from './footer.js';
80
80
  import { COMMAND_CODE_CREDITS_URL, COMMAND_CODE_SUBSCRIPTIONS_URL, COMMAND_CODE_USAGE_URL, commandCodePeriodStart, commandCodeSourceFor, crossedQuotaThresholds, DEEPSEEK_PUBLIC_BASE_URL, formatAccountBalance, formatFooterBalance, formatQuotaSnapshot, joinUrl, OPENAI_COMPAT_BALANCE_PATHS, OPENCODE_GO_USAGE_URL, OPENCODE_ZEN_BASE_URL, openCodeApiErrorMessage, openCodeSourceFor, parseCommandCodeQuota, parseDeepSeekBalance, parseOpenAiCompatibleBalance, parseOpenCodeGoQuota, parseSuperGrokBilling, preferredQuotaWindow, quotaAlertText, quotaRefreshEverySteps, reasoningEffortsForDefault, SUPERGROK_BILLING_URL, tightestQuotaWindow, } from './quota.js';
81
81
  import { appendSubagentLog, foldSubagentUserLog, applyTurnEndToPlan, buildSubagentHeader, cardCategoryLabel, cardCategoryOf, clipSubagentActivity, compactionHeaderText, formatCompactCommandError, isPromptInjectionMessage, matchTranscriptRows, parseFindQuery, planCloseNudgeText, planMarkdownFromArgs, planDockNote, planIsLive, planTitleFromMarkdown, promptInjectionSources, promptInjectionTitle, describeSubagentFailure, subagentChipSummary, subagentDisplayName, subagentInspectLines, subagentRowFromSpawnTool, todoItemKind, askQuestions, todoProgressLabel, TODO_STATUS_MARK, } from './plan.js';
@@ -448,84 +448,27 @@ const QUOTA_RETRY_MS = 15_000;
448
448
  * screen's own tests have one source: the *order* is the product decision (B2.6 §3
449
449
  * keeps it unchanged), and this is where it is written down.
450
450
  */
451
- const SETUP_STEPS = ['provider', 'id', 'base-url', 'key', 'models', 'models-pick', 'model-default', 'context', 'confirm'];
452
- /** The steps that show a text field (the others are pickers or the confirmation). */
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
- }
451
+ /** SGR mouse report: `\x1b[<button;column;row` + `M` (press/motion) or `m` (release). */
452
+ const SGR_MOUSE_RE = /^\x1b\[<(\d+);(\d+);(\d+)([Mm])$/u;
476
453
  /**
477
- * Split one read into the keys it carries.
454
+ * Whether one unit carries more than one Unicode code point.
478
455
  *
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.
456
+ * This is the test `composerUnit` needs, and `unit.length > 1` is not it: `.length`
457
+ * counts UTF-16 code units, and an astral character — `🚀`, `𠮷`, an emoji with a
458
+ * modifier — is two of those while being a single code point. Reading the first code
459
+ * point and comparing the remainder costs nothing on the hot path (ASCII answers 1, so
460
+ * the comparison is `length > 1` as before) and never allocates, which `Array.from`
461
+ * would. A lone surrogate answers 1, which is the honest reading: it is one code unit
462
+ * and the Reader cannot produce one from the wire.
489
463
  */
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;
464
+ function carriesMultipleCodePoints(unit) {
465
+ if (unit.length <= 1)
466
+ return false;
467
+ return unit.length > ((unit.codePointAt(0) ?? 0) > 0xffff ? 2 : 1);
528
468
  }
469
+ const SETUP_STEPS = ['provider', 'id', 'base-url', 'key', 'models', 'models-pick', 'model-default', 'context', 'confirm'];
470
+ /** The steps that show a text field (the others are pickers or the confirmation). */
471
+ const SETUP_FIELD_STEPS = new Set(['provider', 'id', 'base-url', 'key', 'models', 'context']);
529
472
  /**
530
473
  * The durable event types the plan projection reads (B2.5).
531
474
  *
@@ -882,7 +825,6 @@ export class SshTui {
882
825
  input = '';
883
826
  cursor = 0;
884
827
  inputFolded = false;
885
- inPaste = false;
886
828
  history = [];
887
829
  historyIndex = -1;
888
830
  /** Live input parked while browsing history with ↑. Restored by ↓ past the newest item. */
@@ -1136,7 +1078,6 @@ export class SshTui {
1136
1078
  onReattach;
1137
1079
  onDetach;
1138
1080
  renderTimer;
1139
- decoder = new StringDecoder('utf8');
1140
1081
  color;
1141
1082
  maxToolOutputLines;
1142
1083
  showReasoning;
@@ -1496,8 +1437,6 @@ export class SshTui {
1496
1437
  * `turn/start` is the only thing that resets it.
1497
1438
  */
1498
1439
  reasoningExpandedChoice;
1499
- escapeBuffer = '';
1500
- escapeTimer;
1501
1440
  /**
1502
1441
  * How long an acknowledgement or a warning stays on the chrome.
1503
1442
  *
@@ -1512,11 +1451,28 @@ export class SshTui {
1512
1451
  feedbackTtlMs = resolveFeedbackTtlMs(process.env);
1513
1452
  feedbackTimer;
1514
1453
  /**
1515
- * Cursor-position replies removed from the relay's stdin stream. A launcher
1516
- * from an older release (or a reply that raced its own probe) would otherwise
1517
- * type `[17;1R` into the prompt or cancel a dialog with a bare ESC.
1454
+ * The input Reader (AD-21): decoding, reply filtering, escape assembly, paste
1455
+ * framing and unit classification live there; the frames come back through
1456
+ * `handleInputText` / `consumeFrame`, which own the routing and the semantics.
1518
1457
  */
1519
- inputGuard = new TerminalInputGuard(text => this.handleInputText(text));
1458
+ reader = new InputReader({
1459
+ onRead: text => this.handleInputText(text),
1460
+ onFrame: frame => this.consumeFrame(frame),
1461
+ });
1462
+ /**
1463
+ * The Router (AD-22, W-2): the one place a unit's owner is decided.
1464
+ *
1465
+ * `ownershipView` is the read-only snapshot it answers from — one object, rewritten
1466
+ * per decision, so a keystroke allocates nothing (W-2 is a hot-path migration).
1467
+ */
1468
+ router = new InputRouter();
1469
+ ownershipView = {
1470
+ screenKind: undefined,
1471
+ surfaceKind: undefined,
1472
+ surfaceHasField: false,
1473
+ composerEmpty: true,
1474
+ workspaceCards: false,
1475
+ };
1520
1476
  thinkingStartedAt;
1521
1477
  waitStartedAt;
1522
1478
  /**
@@ -1546,6 +1502,15 @@ export class SshTui {
1546
1502
  cwdChipRow;
1547
1503
  /** Set when a card expand/collapse moves chrome; next paint full-redraws. */
1548
1504
  forceFullPaint = false;
1505
+ /**
1506
+ * How many times the Screen has been dismissed.
1507
+ *
1508
+ * A read is not a key: its units are delivered one at a time, and a Screen owns the
1509
+ * read it was sent — once it is gone the rest is dropped (0.8.3's rule, W-3a keeps
1510
+ * it). Counting dismissals rather than comparing the Screen object keeps that check
1511
+ * out of the owner-read ratchet, which exists to stop new *ownership* reads.
1512
+ */
1513
+ screenDismissals = 0;
1549
1514
  /** Highest row the last budgeted frame could not paint, resumed next tick. */
1550
1515
  paintResume;
1551
1516
  /** How many times a display has come back after a drop in this Host's life. */
@@ -2409,12 +2374,9 @@ export class SshTui {
2409
2374
  if (this.renderTimer !== undefined)
2410
2375
  clearInterval(this.renderTimer);
2411
2376
  this.renderTimer = undefined;
2412
- if (this.escapeTimer !== undefined)
2413
- clearTimeout(this.escapeTimer);
2414
2377
  if (this.feedbackTimer !== undefined)
2415
2378
  clearTimeout(this.feedbackTimer);
2416
- this.escapeTimer = undefined;
2417
- this.inputGuard.stop();
2379
+ this.reader.stop();
2418
2380
  process.stdin.removeListener('data', this.handleData);
2419
2381
  process.stdout.removeListener('resize', this.onDirectResize);
2420
2382
  process.stdout.removeListener('error', this.handleIoError);
@@ -4054,6 +4016,7 @@ export class SshTui {
4054
4016
  if (this.screen === undefined)
4055
4017
  return;
4056
4018
  this.screen = undefined;
4019
+ this.screenDismissals += 1;
4057
4020
  this.screenSurface = undefined;
4058
4021
  this.screenSurfaceResolve = undefined;
4059
4022
  // One full repaint when a Screen closes, and it is deliberate: the transcript
@@ -5638,7 +5601,12 @@ export class SshTui {
5638
5601
  column = Math.min(width, promptWidth + inputView.cursorOffset + 1);
5639
5602
  }
5640
5603
  else {
5641
- const pos = cursorVisualPosition(inputView.text, this.cursor, inputTextWidth);
5604
+ // The caret belongs to the field being drawn, like the text above it (W-3b.1a):
5605
+ // `fieldCursor` is the borrowed answer's caret while a Surface has one, and the
5606
+ // draft's otherwise. Reading `this.cursor` here put the terminal's caret — the
5607
+ // cell an IME draws its pre-edit in — at the hidden draft's offset, which is a
5608
+ // different number whenever a Surface borrowed the field.
5609
+ const pos = cursorVisualPosition(inputView.text, fieldCursor, inputTextWidth);
5642
5610
  while (pos.row >= inputDisplayLines.length)
5643
5611
  inputDisplayLines.push('');
5644
5612
  cursorRowOffset = pos.row;
@@ -5801,38 +5769,38 @@ export class SshTui {
5801
5769
  ...(health === undefined ? {} : { warning: { long: health.long, short: health.short } }),
5802
5770
  depth: this.colorDepth,
5803
5771
  color: this.color,
5804
- ...(this.footerEcho === undefined ? {} : { echo: this.footerEcho.text }),
5805
5772
  };
5806
5773
  const stripText = runtimeStrip(stripInput, Math.max(1, width), this.mutedSeparator(), this.spinnerFrame());
5807
- // The acknowledgement the strip could not fit.
5808
- //
5809
- // The echo chip is the lowest-priority group on the stats row, which is exactly
5810
- // the state a working session is in — so a command's confirmation was dropped
5811
- // entirely and the reader saw *no* feedback at all (B2.5, reported). The chip is
5812
- // still where it goes first; when it did not survive, the row that always fits
5813
- // says it instead. One message, one place, no extra rows.
5814
- const missingEcho = this.notice === undefined
5815
- && this.footerEcho !== undefined
5816
- && !stripText.includes(this.footerEcho.text)
5817
- ? this.footerEcho.text
5818
- : undefined;
5819
5774
  // The row is one muted run with the accents spliced in at higher intensity;
5820
5775
  // `muteFooterLine` reopens the mute after every reset so the (already
5821
5776
  // coloured) pips and meters cannot bleed into the text around them.
5822
5777
  const statsLine = clipAnsiToWidth(this.muteFooterLine(stripText), Math.max(1, width));
5823
5778
  this.healthChipRow = undefined;
5824
- // The identity row is composed *after* the strip, because whether it needs to
5825
- // carry the echo depends on what the strip managed to fit (see below).
5779
+ // Every acknowledgement lands on the identity row, and nowhere else.
5780
+ //
5781
+ // The echo used to be the lowest-priority chip *on the meter row*, with the
5782
+ // identity row as the fallback when it did not fit — so which of the two rows a
5783
+ // message appeared on depended on its length and the window's width. The same
5784
+ // `/copy` said `已复制 412 字…(最近回复)` on the row above the last one (it fit)
5785
+ // and `还没有可复制的回复…` on the last one (it did not): two acknowledgements,
5786
+ // two lines, nothing in the product saying why (reported). A confirmation is
5787
+ // read, not measured, so it is not the meter row's business either — the first
5788
+ // footer row answers the five telemetry questions and only those (E-1).
5789
+ //
5790
+ // A notice still outranks an echo: an operational warning must not be hidden by
5791
+ // an acknowledgement that happens to be alive at the same moment. Both are
5792
+ // Host-local, both are transient, and now both are on the row that always fits.
5793
+ const echoText = this.notice === undefined ? this.footerEcho?.text : undefined;
5826
5794
  const statusText = this.notice !== undefined
5827
5795
  ? truncateToWidth(this.notice.text, Math.max(1, width))
5828
- : missingEcho === undefined
5829
- ? fitFooterStatusLine('', identity, Math.max(1, width))
5830
- : truncateToWidth(missingEcho, Math.max(1, width));
5796
+ : echoText !== undefined
5797
+ ? truncateToWidth(echoText, Math.max(1, width))
5798
+ : fitFooterStatusLine('', identity, Math.max(1, width));
5831
5799
  // Accents on an otherwise muted line: the `sub:` chip. Each reset reopens
5832
5800
  // mute, the same way the pre-strip footer did. `styleLine` sanitises first,
5833
5801
  // so the accent is spliced in after.
5834
5802
  let statusLine = this.styleLine('system', statusText);
5835
- if (this.color && this.notice === undefined) {
5803
+ if (this.color && this.notice === undefined && echoText === undefined) {
5836
5804
  // The chip is only accented when it survived the fit: a `sub:` route the
5837
5805
  // row could not afford is not on the line, and `paintFooterSubagentChip`
5838
5806
  // returns the line unchanged when it cannot find its text.
@@ -8802,6 +8770,21 @@ export class SshTui {
8802
8770
  const at = this.fieldCursor();
8803
8771
  this.setField(`${current.slice(0, at)}${text}${current.slice(at)}`, at + text.length);
8804
8772
  }
8773
+ /**
8774
+ * Move the caret of whichever field owns the keyboard to one end (Home/End).
8775
+ *
8776
+ * One implementation for the two owners that carry these keys (W-3b.1): the composer
8777
+ * edits the draft, a Surface with a borrowed field edits the answer, and a Surface
8778
+ * without one has nothing to move — `textIsEditable` is what says which of those the
8779
+ * keyboard is on. Ctrl+A/Ctrl+E reach the same two positions through `composerUnit`,
8780
+ * which asks the same question.
8781
+ */
8782
+ moveFieldCursor(target) {
8783
+ if (!this.textIsEditable())
8784
+ return;
8785
+ this.setField(this.fieldText(), target === 'start' ? 0 : this.fieldText().length);
8786
+ this.markDirty();
8787
+ }
8805
8788
  /** One card's key: a call's batch can repeat a question id across calls. */
8806
8789
  questionCardKey(callId, questionId) {
8807
8790
  return `${callId ?? `live:${this.liveQuestionSeq}`}\u0000${questionId}`;
@@ -11328,10 +11311,7 @@ export class SshTui {
11328
11311
  }
11329
11312
  // ── keyboard ────────────────────────────────────────────────────────────
11330
11313
  handleData = (chunk) => {
11331
- const decoded = this.decoder.write(chunk);
11332
- if (decoded === '')
11333
- return;
11334
- this.inputGuard.push(decoded);
11314
+ this.reader.push(chunk);
11335
11315
  };
11336
11316
  /**
11337
11317
  * One key, handled by the Screen that is up.
@@ -11357,30 +11337,13 @@ export class SshTui {
11357
11337
  }
11358
11338
  // A burst of line breaks in one read is a paste from a console that cannot say so
11359
11339
  // (`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)) {
11340
+ // pasted key or model list survives a terminal without bracketed paste. The Reader
11341
+ // keeps such a read whole for exactly this check — splitting it is what would turn
11342
+ // one paste into a step's worth of Enters.
11343
+ if (InputReader.looksLikePasteBurst(combined)) {
11364
11344
  this.pasteIntoScreen(combined);
11365
11345
  return;
11366
11346
  }
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
- }
11384
11347
  // The setup Screen owns its own field, its own navigation and its own keys: it
11385
11348
  // consumes everything while it is up, and nothing reaches the workspace's
11386
11349
  // composer, history or transcript (B2.6 §4).
@@ -11524,31 +11487,86 @@ export class SshTui {
11524
11487
  screen.notice = caveat ?? notice;
11525
11488
  this.markDirty();
11526
11489
  }
11490
+ /**
11491
+ * One read, as the Reader framed it: paste traffic is dispatched per frame,
11492
+ * everything else is routed (W-2 lifts the routing itself into a Router).
11493
+ */
11527
11494
  handleInputText(text) {
11528
- const combined = this.escapeBuffer + text;
11529
- this.escapeBuffer = '';
11530
- if (this.escapeTimer !== undefined) {
11531
- clearTimeout(this.escapeTimer);
11532
- this.escapeTimer = undefined;
11533
- }
11534
- // Bracketed paste: terminals wrap pasted content in \x1b[200~ ... \x1b[201~.
11535
- // While inside a paste, CR/LF are literal input characters rather than
11536
- // submit, so copying a multi-line error message arrives as one message.
11537
- if (this.inPaste || combined.includes('\x1b[200~') || combined.includes('\x1b[201~')) {
11538
- this.processPasteChunk(combined);
11539
- return;
11540
- }
11541
- // A key the user moved, and a key their move displaced. Consulted before any
11542
- // routing so a plain control byte reaches it as readily as a CSI sequence;
11543
- // everything else keeps the handling below, which is what makes the defaults
11544
- // unchanged by construction.
11495
+ const combined = this.reader.assemble(text);
11496
+ // A read is not a key. The Reader decides where one unit ends — complete
11497
+ // sequences, Alt chords, control bytes, CR/LF, and runs of typing — and each unit
11498
+ // is framed once and routed once, whichever owner it belongs to (AD-20 §3, W-3a).
11499
+ // Bracketed paste is framed there too: while inside a paste, CR/LF are literal
11500
+ // characters rather than submit, so a copied multi-line error message arrives as
11501
+ // one message.
11502
+ //
11503
+ // A Screen owns the read it was sent, including the units after it closes: 0.8.3
11504
+ // dropped the rest of such a read (`handleScreenInput` returned as soon as its
11505
+ // Screen was gone) and that rule is a safety one, not an artefact — the keys after
11506
+ // a dismissal were addressed to the Screen, and letting them land on whatever is
11507
+ // behind it is how a paste ended up in the composer. A Surface is the other way
11508
+ // round, and deliberately: its answer is a character like any other, and the rest
11509
+ // of the read is routed on (`yes` typed at a confirm is refused by the Surface and
11510
+ // leaves the draft alone — pinned by `yes-typed-at-dialog`).
11511
+ const dismissals = this.screenDismissals;
11512
+ for (const frame of this.reader.read(combined)) {
11513
+ if (this.screenDismissals !== dismissals)
11514
+ return;
11515
+ this.consumeFrame(frame);
11516
+ }
11517
+ }
11518
+ /** One framed unit, at the owner that takes it. */
11519
+ consumeFrame(frame) {
11520
+ switch (frame.kind) {
11521
+ case 'paste':
11522
+ this.dispatchPaste(frame.text);
11523
+ return;
11524
+ // A sequence tail, an Alt remainder or a released hold: typed as it is, with
11525
+ // no keymap lookup — which is what the inline version did at those three sites.
11526
+ case 'literal':
11527
+ this.handlePlainText(frame.text);
11528
+ return;
11529
+ case 'escape':
11530
+ this.handleChar('\x1b');
11531
+ return;
11532
+ // A device reply is the prober's input, never an owner's. W-1 keeps today's
11533
+ // behaviour (dropped here); W-2 wires it to the prober that asked.
11534
+ case 'reply': return;
11535
+ // `key` and `burst` reach the same routing today. The distinction is what W-2
11536
+ // routes on (one key versus several in one read), so the Reader decides it.
11537
+ case 'key':
11538
+ this.route(frame.sequence);
11539
+ return;
11540
+ case 'burst':
11541
+ this.route(frame.text);
11542
+ return;
11543
+ }
11544
+ }
11545
+ /**
11546
+ * One read from the Reader, as the ladder has always taken it.
11547
+ *
11548
+ * Two kinds of table live here and they are not the same thing:
11549
+ *
11550
+ * - **binding** — which byte means which action (the keymap's rebindable actions,
11551
+ * the Alt chords, the SS3 tail). Those stay; AD-20 F-5 wants one table per key.
11552
+ * - **ownership** — who takes the unit. That question is the Router's now, and it
11553
+ * is asked exactly once per unit, before any binding table is consulted: a
11554
+ * Screen owns the read outright, so the tables below are for the unlayered case,
11555
+ * which is the order the ladder always had.
11556
+ *
11557
+ * The parsing branches that are neither (a read that could still grow — the Reader
11558
+ * holds it; an escape sequence nobody claimed — dropped) stay here as well.
11559
+ */
11560
+ route(combined) {
11561
+ // A key the user moved, and a key their move displaced.
11545
11562
  const moved = combined === '' ? undefined : this.keymap.sequences.get(combined);
11546
- // A Screen replaces the workspace and owns every key outright. There is no
11547
- // composer behind it, no transcript to fall through to and no Surface it could
11548
- // share with: one gate, instead of the ten `dialog?.kind === 'inspect'` checks
11549
- // this replaced (B2.1).
11550
- if (this.screen !== undefined) {
11551
- this.handleScreenInput(combined, moved);
11563
+ const kind = this.readKind(combined);
11564
+ this.observe(kind, combined);
11565
+ const owner = this.router.route(kind, combined, this.ownershipView);
11566
+ // A Screen owns the read outright (AD-20 §4.3 priority 3): nothing below this
11567
+ // line runs for it — no keymap action, no Alt chord, no bare ESC for the composer.
11568
+ if (owner === 'screen') {
11569
+ this.ownerScreen(combined, moved);
11552
11570
  return;
11553
11571
  }
11554
11572
  if (this.dialog === undefined && combined !== '') {
@@ -11559,124 +11577,14 @@ export class SshTui {
11559
11577
  if (this.dialog === undefined && this.keymap.suppressed.has(combined))
11560
11578
  return;
11561
11579
  }
11562
- const escape = /^\x1b\[([A-D])$/u;
11563
- const match = combined.match(escape);
11564
- if (match !== null) {
11565
- switch (match[1]) {
11566
- case 'A':
11567
- if (this.moveQuestionCursor(-1)) {
11568
- return;
11569
- return;
11570
- return;
11571
- }
11572
- else if (this.dialog !== undefined) {
11573
- // A surface owns the keyboard: an arrow it does not use is not the
11574
- // transcript's. Falling through moved the selection behind the dialog,
11575
- // and with a non-empty field it rewrote the field from history.
11576
- return;
11577
- }
11578
- else if (this.suggestionsVisible()) {
11579
- this.suggestionIndex = Math.max(0, this.suggestionIndex - 1);
11580
- this.markDirty();
11581
- }
11582
- else if (this.fieldText() === '' && this.collapsibleRows().length > 0) {
11583
- this.moveFocus(-1);
11584
- }
11585
- else {
11586
- this.historyBack();
11587
- }
11588
- return;
11589
- case 'B':
11590
- if (this.moveQuestionCursor(1)) {
11591
- return;
11592
- return;
11593
- return;
11594
- }
11595
- else if (this.dialog !== undefined) {
11596
- return;
11597
- }
11598
- else if (this.suggestionsVisible()) {
11599
- this.suggestionIndex = Math.min(this.commandSuggestions.length - 1, this.suggestionIndex + 1);
11600
- this.markDirty();
11601
- }
11602
- else if (this.fieldText() === '' && this.collapsibleRows().length > 0) {
11603
- this.moveFocus(1);
11604
- }
11605
- else {
11606
- this.historyForward();
11607
- }
11608
- return;
11609
- case 'C':
11610
- if (this.textIsEditable())
11611
- this.moveCursor(1);
11612
- return;
11613
- case 'D':
11614
- if (this.textIsEditable())
11615
- this.moveCursor(-1);
11616
- return;
11617
- }
11618
- }
11619
- const sgrMouse = /^\x1b\[<(\d+);(\d+);(\d+)([Mm])$/u.exec(combined);
11620
- if (sgrMouse !== null) {
11621
- const button = Number(sgrMouse[1]);
11622
- const x = Number(sgrMouse[2]);
11623
- const y = Number(sgrMouse[3]);
11624
- if (sgrMouse[4] === 'M') {
11625
- if (button === 64) {
11626
- this.scrollActiveSurface(3);
11627
- return;
11628
- }
11629
- if (button === 65) {
11630
- this.scrollActiveSurface(-3);
11631
- return;
11632
- }
11633
- // 32 is "motion with the left button held": a drag over a reply. Shift
11634
- // adds 4 to every button code, and a terminal that still forwards the
11635
- // report while Shift is down (instead of keeping it for its own
11636
- // selection) must drag exactly like an unshifted one — otherwise the
11637
- // gesture the user was told to fall back on does nothing here.
11638
- if (button === 32 || button === 36) {
11639
- this.extendMouseSelection(y, x);
11640
- return;
11641
- }
11642
- if (button === 0 || button === 4) {
11643
- this.beginMouseSelection(y, x);
11644
- return;
11645
- }
11646
- return;
11647
- }
11648
- // A release ends either a drag (copy what it covered) or a plain click.
11649
- if (button === 0 || button === 4)
11650
- this.endMouseSelection(y, x);
11651
- return;
11652
- }
11653
- if (combined === '\x1b[5~') {
11654
- this.scrollActiveSurface(Math.max(3, Math.floor(this.screenRows() / 2)));
11580
+ if (this.reader.isDeviceReply(combined)) {
11581
+ this.consumeFrame({ kind: 'reply', sequence: combined });
11655
11582
  return;
11656
11583
  }
11657
- if (combined === '\x1b[6~') {
11658
- this.scrollActiveSurface(-Math.max(3, Math.floor(this.screenRows() / 2)));
11659
- return;
11660
- }
11661
- if (parseCursorPositionReply(combined) !== undefined)
11662
- return;
11663
- if (combined === '\x1b[H' || combined === '\x1b[1~') {
11664
- this.cursor = 0;
11665
- this.markDirty();
11666
- return;
11667
- }
11668
- if (combined === '\x1b[F' || combined === '\x1b[4~') {
11669
- this.cursor = this.input.length;
11670
- this.markDirty();
11671
- return;
11672
- }
11673
- if (combined === '\x1b[3~') {
11674
- this.deleteAtCursor();
11675
- return;
11676
- }
11677
- // kitty / CSI-u Ctrl+Shift+C (codepoint 99, mods 6 = Ctrl+Shift)
11678
- if (combined === '\x1b[99;6u') {
11679
- this.copyKey();
11584
+ // The sequence shapes the ladder matched before its hold window: they go to their
11585
+ // owner as one unit, and only what is left over can still grow (the Reader holds it).
11586
+ if (this.isSequenceShape(combined)) {
11587
+ this.dispatchWith(owner, combined, moved);
11680
11588
  return;
11681
11589
  }
11682
11590
  const ss3 = /^\x1bO[A-Z]/u.exec(combined);
@@ -11686,30 +11594,10 @@ export class SshTui {
11686
11594
  this.handlePlainText(rest);
11687
11595
  return;
11688
11596
  }
11689
- if (isEscapePrefix(combined)) {
11690
- this.escapeBuffer = combined;
11691
- this.escapeTimer = setTimeout(() => {
11692
- this.escapeTimer = undefined;
11693
- const pending = this.escapeBuffer;
11694
- this.escapeBuffer = '';
11695
- if (pending === '\x1b') {
11696
- this.handleChar('\x1b');
11697
- }
11698
- else if (pending === '\x1bO') {
11699
- // ESC O without an SS3 final byte is an Alt+O keystroke, not a
11700
- // function key.
11701
- this.handlePlainText('O');
11702
- }
11703
- else if (pending.startsWith('\x1b[') || pending.startsWith('\x1bO')) {
11704
- // An escape sequence that never completed: consume it silently
11705
- // instead of treating its ESC byte as a cancel.
11706
- }
11707
- else if (pending !== '') {
11708
- this.handlePlainText(pending);
11709
- }
11710
- }, 60);
11597
+ // A read that could still grow into a sequence is the Reader's to hold, and the
11598
+ // window is the same 60 ms it always was (AD-21: moved, not changed).
11599
+ if (this.reader.hold(combined))
11711
11600
  return;
11712
- }
11713
11601
  if (combined.startsWith('\x1b[') || combined.startsWith('\x1bO')) {
11714
11602
  // Unknown escape sequence (including SS3 function keys) — consume
11715
11603
  // without side effects.
@@ -11741,167 +11629,321 @@ export class SshTui {
11741
11629
  this.stepSearch(-1);
11742
11630
  return;
11743
11631
  }
11632
+ // The `/find` seed is composer *text*, so it may only be typed into a field
11633
+ // the composer owns (W-3b.3a). This branch runs before any owner is asked
11634
+ // (the Alt chords are workspace-global, AD-20 §1.3 step 13), so with a
11635
+ // Surface up it used to write the hidden draft — the one thing F-2 forbids —
11636
+ // and the reader saw their next message replaced by `/find `. A chord is not
11637
+ // an owner, so the key is consumed here instead of reaching behind the
11638
+ // Surface. `textIsEditable()` alone is not this question: it answers "a field
11639
+ // is editable", and a borrowed answer is one — the seed is the *composer's*
11640
+ // field, which is exactly `borrowedText === undefined`.
11744
11641
  if (alt === 'f' || alt === 'F' || alt === '/') {
11745
- this.input = '/find ';
11746
- this.cursor = this.input.length;
11747
- this.markDirty();
11642
+ if (this.textIsEditable() && this.borrowedText === undefined) {
11643
+ this.input = '/find ';
11644
+ this.cursor = this.input.length;
11645
+ this.markDirty();
11646
+ }
11748
11647
  return;
11749
11648
  }
11750
11649
  // Other Alt+<key>: ignore ESC so it does not cancel, type the remainder.
11751
11650
  this.handlePlainText(alt);
11752
11651
  return;
11753
11652
  }
11754
- this.handlePlainText(combined);
11653
+ this.dispatchWith(owner, combined, moved);
11755
11654
  }
11756
- /** Handle one data chunk that may contain bracketed-paste markers. */
11757
- processPasteChunk(combined) {
11758
- let index = 0;
11759
- while (index < combined.length) {
11760
- if (combined.startsWith('\x1b[200~', index)) {
11761
- this.inPaste = true;
11762
- index += 6;
11763
- continue;
11764
- }
11765
- if (combined.startsWith('\x1b[201~', index)) {
11766
- this.inPaste = false;
11767
- index += 6;
11768
- continue;
11769
- }
11770
- let end = index;
11771
- while (end < combined.length
11772
- && !combined.startsWith('\x1b[200~', end)
11773
- && !combined.startsWith('\x1b[201~', end)) {
11774
- end += 1;
11775
- }
11776
- if (end > index) {
11777
- const part = combined.slice(index, end);
11778
- if (this.inPaste)
11779
- this.handlePasteText(part);
11780
- else
11781
- this.handlePlainText(part);
11782
- index = end;
11783
- }
11784
- else {
11785
- index += 1;
11786
- }
11655
+ /**
11656
+ * The Router's read-only view, rewritten per decision (no allocation).
11657
+ *
11658
+ * The card question is the only expensive one — `collapsibleRows()` walks the
11659
+ * visible transcript — so it is answered only for the arrow that asks it, and only
11660
+ * when nothing is layered over the workspace.
11661
+ */
11662
+ observe(kind, unit) {
11663
+ const view = this.ownershipView;
11664
+ const screen = this.screen;
11665
+ const surface = this.dialog;
11666
+ view.screenKind = screen?.kind;
11667
+ view.surfaceKind = surface?.kind;
11668
+ view.surfaceHasField = this.borrowedText !== undefined;
11669
+ view.composerEmpty = this.fieldText() === '';
11670
+ view.workspaceCards = screen === undefined && surface === undefined
11671
+ && kind === 'sequence'
11672
+ && (unit === '\x1b[A' || unit === '\x1b[B')
11673
+ && view.composerEmpty
11674
+ && this.collapsibleRows().length > 0;
11675
+ }
11676
+ /**
11677
+ * Whether a read is one of the sequence shapes the ladder claims as a whole.
11678
+ *
11679
+ * These are matched *before* the Reader's hold window — the order the ladder always
11680
+ * had (`ESC [ 5 ~` is a page key, not a prefix that might still grow).
11681
+ */
11682
+ isSequenceShape(unit) {
11683
+ return /^\x1b\[[A-D]$/u.test(unit)
11684
+ || SGR_MOUSE_RE.test(unit)
11685
+ || unit === '\x1b[5~' || unit === '\x1b[6~'
11686
+ || unit === '\x1b[H' || unit === '\x1b[1~' || unit === '\x1b[F' || unit === '\x1b[4~'
11687
+ || unit === '\x1b[3~' || unit === '\x1b[99;6u';
11688
+ }
11689
+ /** The unit class of one read, by shape (the Reader's classification, allocation-free). */
11690
+ readKind(combined) {
11691
+ if (combined === '\x1b')
11692
+ return 'escape';
11693
+ if (combined.length === 1)
11694
+ return combined < ' ' || combined === '\x7f' ? 'control' : 'text';
11695
+ return combined.startsWith('\x1b') ? 'sequence' : 'text';
11696
+ }
11697
+ /** The unit class of one character — what `handleChar` routes. */
11698
+ charKind(char) {
11699
+ if (char === '\x1b')
11700
+ return 'escape';
11701
+ return char < ' ' || char === '\x7f' ? 'control' : 'text';
11702
+ }
11703
+ /** One unit, one owner: the Router decides, the owner adapter runs it. */
11704
+ dispatch(kind, unit, moved) {
11705
+ this.observe(kind, unit);
11706
+ this.dispatchWith(this.router.route(kind, unit, this.ownershipView), unit, moved);
11707
+ }
11708
+ /** Run a unit at the owner the Router already named. */
11709
+ dispatchWith(owner, unit, moved) {
11710
+ switch (owner) {
11711
+ case 'screen':
11712
+ this.ownerScreen(unit, moved);
11713
+ return;
11714
+ case 'surface':
11715
+ this.ownerSurface(unit, moved);
11716
+ return;
11717
+ case 'composer':
11718
+ this.ownerComposer(unit, moved);
11719
+ return;
11720
+ case 'workspace':
11721
+ this.ownerWorkspace(unit, moved);
11722
+ return;
11787
11723
  }
11788
11724
  }
11789
11725
  /**
11790
- * Insert pasted text into the field that owns the keyboard; CR/LF are literal
11791
- * newlines.
11726
+ * A paste payload: the Router decides, the paste path executes.
11792
11727
  *
11793
- * A paste is text input, so it goes through the same ownership question ordinary
11794
- * typing does: into the composer's draft when the composer owns the keyboard, and
11795
- * into a borrowed field when a surface that has one does. A surface without a
11796
- * text field (a confirmation, a menu, a report) takes no paste at all — the
11797
- * alternative was writing the composer's hidden draft behind it, which is how a
11798
- * paste into an approval dialog could silently rewrite the message being written.
11728
+ * A paste is text, so the field that owns the keyboard takes it — and a Screen that
11729
+ * owns the interaction terminates it (its own field or nothing), never a field
11730
+ * behind it (AD-20 §4.3 priority 1). `handlePasteText` still re-asks that per owner;
11731
+ * that is the compat path W-3 folds into the Router (marked there).
11732
+ */
11733
+ dispatchPaste(text) {
11734
+ this.observe('paste', text);
11735
+ if (this.router.route('paste', text, this.ownershipView) === 'workspace')
11736
+ return;
11737
+ this.handlePasteText(text);
11738
+ }
11739
+ /**
11740
+ * The Screen adapter: a Screen owns the keyboard outright (AD-20 §4.3 priority 3).
11799
11741
  *
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).
11742
+ * One unit goes in — the Reader split the read, so a Screen sees the same keys the
11743
+ * composer would (W-3a) and `handleScreenInput` is where the paste-burst rule stays.
11744
+ * Its own `screen === undefined` guard stays too: a release raced by a teardown
11745
+ * reaches it, and refusing there is the adapter's own business rather than a second
11746
+ * ownership decision.
11804
11747
  */
11805
- handlePasteText(text) {
11806
- const normalized = text.replaceAll('\r\n', '\n').replaceAll('\r', '\n');
11807
- if (normalized === '')
11748
+ ownerScreen(unit, moved) {
11749
+ this.handleScreenInput(unit, moved ?? this.keymap.sequences.get(unit));
11750
+ }
11751
+ /**
11752
+ * The Surface adapter: a dialog owns the keyboard while it is up.
11753
+ *
11754
+ * Its arrows move a question's cursor (or nothing — an arrow a Surface does not use
11755
+ * is not the transcript's), the page keys are not its business, and its mouse reports
11756
+ * take the same path they always did so the refusal happens where it always did, down
11757
+ * to the remembered press. Its own editing keys are `Home`/`End`/`Delete` (W-3b.1):
11758
+ * they act on the field the Surface borrowed — the one row the reader can see — and a
11759
+ * Surface that borrowed none has no field for them, so it consumes them. The kitty
11760
+ * copy key falls out with the other sequences below: a Surface has no copy contract,
11761
+ * and this round did not invent one. Everything else goes to the shared
11762
+ * composer/Surface body, whose `dialog` branches are the Surface's own behaviour.
11763
+ */
11764
+ ownerSurface(unit, moved) {
11765
+ if (unit === '\x1b[A') {
11766
+ this.moveQuestionCursor(-1);
11808
11767
  return;
11809
- if (this.screen !== undefined) {
11810
- this.pasteIntoScreen(normalized);
11768
+ }
11769
+ if (unit === '\x1b[B') {
11770
+ this.moveQuestionCursor(1);
11811
11771
  return;
11812
11772
  }
11813
- if (!this.textIsEditable())
11773
+ const sgr = SGR_MOUSE_RE.exec(unit);
11774
+ if (sgr !== null) {
11775
+ const button = Number(sgr[1]);
11776
+ if (button === 64 || button === 65)
11777
+ return;
11778
+ this.mouseGesture(sgr);
11814
11779
  return;
11815
- this.leaveHistoryBrowse();
11816
- const current = this.fieldText();
11817
- const at = this.fieldCursor();
11818
- this.setField(`${current.slice(0, at)}${normalized}${current.slice(at)}`, at + normalized.length);
11819
- if (this.borrowedText === undefined) {
11820
- const cols = Math.max(10, this.screenColumns());
11821
- const lineWidth = Math.max(1, cols - 2);
11822
- if (normalized.includes('\n') || displayWidth(this.fieldText()) > lineWidth)
11823
- this.inputFolded = true;
11824
11780
  }
11825
- this.markDirty();
11781
+ if (unit === '\x1b[H' || unit === '\x1b[1~') {
11782
+ this.moveFieldCursor('start');
11783
+ return;
11784
+ }
11785
+ if (unit === '\x1b[F' || unit === '\x1b[4~') {
11786
+ this.moveFieldCursor('end');
11787
+ return;
11788
+ }
11789
+ if (unit === '\x1b[3~') {
11790
+ // Only a borrowed field can be edited: the draft behind the Surface is not the
11791
+ // field on screen, and an answer the reader cannot see is not an answer.
11792
+ if (this.textIsEditable())
11793
+ this.deleteAtCursor();
11794
+ return;
11795
+ }
11796
+ if (unit.startsWith('\x1b[') || unit.startsWith('\x1bO'))
11797
+ return;
11798
+ this.composerUnit(unit, moved);
11826
11799
  }
11827
11800
  /**
11828
- * Give a paste to the field the Screen owns, or to nobody.
11801
+ * The Composer adapter.
11829
11802
  *
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.
11803
+ * Text — one character or many — is typed: the burst rule first, then one character
11804
+ * at a time through the Router, which is what lets ownership change inside a single
11805
+ * read (a Surface answering `y` with the composer taking the `es` behind it). The
11806
+ * sequence shapes the composer owns are the arrows (the suggestion list, else
11807
+ * history) and the caret keys — which it now only ever gets while it owns the
11808
+ * keyboard: with a Surface up, the Router hands those keys to the Surface (W-3b.1).
11836
11809
  */
11837
- pasteIntoScreen(text) {
11838
- const state = this.onboarding;
11839
- if (this.screen?.kind !== 'setup' || state === undefined || state.saving)
11810
+ ownerComposer(unit, moved) {
11811
+ if (unit === '\x1b[A') {
11812
+ this.arrowHistory(-1);
11840
11813
  return;
11841
- if (!SETUP_FIELD_STEPS.has(state.step))
11814
+ }
11815
+ if (unit === '\x1b[B') {
11816
+ this.arrowHistory(1);
11842
11817
  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 === '')
11818
+ }
11819
+ if (unit === '\x1b[C') {
11820
+ if (this.textIsEditable())
11821
+ this.moveCursor(1);
11850
11822
  return;
11851
- this.setupInsert(state, flat);
11852
- this.markDirty();
11823
+ }
11824
+ if (unit === '\x1b[D') {
11825
+ if (this.textIsEditable())
11826
+ this.moveCursor(-1);
11827
+ return;
11828
+ }
11829
+ // The caret keys go through the field the keyboard is editing, like Ctrl+A/Ctrl+E
11830
+ // below: with no Surface up that field *is* the draft, so this is the write it
11831
+ // always was — and it can no longer be the wrong field if a later round routes a
11832
+ // unit here while a field is borrowed.
11833
+ if (unit === '\x1b[H' || unit === '\x1b[1~') {
11834
+ this.moveFieldCursor('start');
11835
+ return;
11836
+ }
11837
+ if (unit === '\x1b[F' || unit === '\x1b[4~') {
11838
+ this.moveFieldCursor('end');
11839
+ return;
11840
+ }
11841
+ if (unit === '\x1b[3~') {
11842
+ this.deleteAtCursor();
11843
+ return;
11844
+ }
11845
+ if (unit === '\x1b[99;6u') {
11846
+ this.copyKey();
11847
+ return;
11848
+ }
11849
+ this.composerUnit(unit, moved);
11853
11850
  }
11854
11851
  /**
11855
- * Whether the keys that edit text belong to a field right now.
11856
- *
11857
- * The composer always has one; an open surface only has one when it borrowed it
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.
11852
+ * The Workspace adapter: the transcript owns the mouse, the page keys and the focus
11853
+ * ring — and the ring only when the Router said so (empty field, a card to land on).
11863
11854
  */
11864
- textIsEditable() {
11865
- if (this.screen !== undefined)
11866
- return false;
11867
- return this.dialog === undefined || this.borrowedText !== undefined;
11855
+ ownerWorkspace(unit, moved) {
11856
+ void moved;
11857
+ const sgr = SGR_MOUSE_RE.exec(unit);
11858
+ if (sgr !== null) {
11859
+ const button = Number(sgr[1]);
11860
+ if (button === 64) {
11861
+ this.scrollWorkspace(3);
11862
+ return;
11863
+ }
11864
+ if (button === 65) {
11865
+ this.scrollWorkspace(-3);
11866
+ return;
11867
+ }
11868
+ this.mouseGesture(sgr);
11869
+ return;
11870
+ }
11871
+ const page = Math.max(3, Math.floor(this.screenRows() / 2));
11872
+ if (unit === '\x1b[5~') {
11873
+ this.scrollWorkspace(page);
11874
+ return;
11875
+ }
11876
+ if (unit === '\x1b[6~') {
11877
+ this.scrollWorkspace(-page);
11878
+ return;
11879
+ }
11880
+ if (unit === '\x1b[A') {
11881
+ this.moveFocus(-1);
11882
+ return;
11883
+ }
11884
+ if (unit === '\x1b[B') {
11885
+ this.moveFocus(1);
11886
+ return;
11887
+ }
11868
11888
  }
11869
- handlePlainText(text) {
11870
- // Fallback for terminals without bracketed paste: a burst of multiple line
11871
- // breaks in one chunk is a paste, not repeated Enter presses.
11872
- if (looksLikePasteBurst(text)) {
11873
- this.handlePasteText(text);
11889
+ /** An arrow the composer keeps: the suggestion list while it is up, else history. */
11890
+ arrowHistory(direction) {
11891
+ if (this.suggestionsVisible()) {
11892
+ this.suggestionIndex = direction < 0
11893
+ ? Math.max(0, this.suggestionIndex - 1)
11894
+ : Math.min(this.commandSuggestions.length - 1, this.suggestionIndex + 1);
11895
+ this.markDirty();
11874
11896
  return;
11875
11897
  }
11876
- let previous = '';
11877
- for (const char of text) {
11878
- // Windows terminals may deliver Enter as CRLF; consume only the first half.
11879
- if (char === '\n' && previous === '\r') {
11880
- previous = char;
11881
- continue;
11898
+ if (direction < 0)
11899
+ this.historyBack();
11900
+ else
11901
+ this.historyForward();
11902
+ }
11903
+ /** Press, motion and release: selection, links and card toggles — the transcript's. */
11904
+ mouseGesture(sgr) {
11905
+ const button = Number(sgr[1]);
11906
+ const x = Number(sgr[2]);
11907
+ const y = Number(sgr[3]);
11908
+ if (sgr[4] === 'M') {
11909
+ // 32 is "motion with the left button held": a drag over a reply. Shift adds 4 to
11910
+ // every button code, and a terminal that still forwards the report while Shift is
11911
+ // down must drag exactly like an unshifted one.
11912
+ if (button === 32 || button === 36) {
11913
+ this.extendMouseSelection(y, x);
11914
+ return;
11882
11915
  }
11883
- if (char === '\r' && previous === '\n') {
11884
- previous = char;
11885
- continue;
11916
+ if (button === 0 || button === 4) {
11917
+ this.beginMouseSelection(y, x);
11918
+ return;
11886
11919
  }
11887
- this.handleChar(char);
11888
- previous = char;
11920
+ return;
11889
11921
  }
11922
+ // A release ends either a drag (copy what it covered) or a plain click.
11923
+ if (button === 0 || button === 4)
11924
+ this.endMouseSelection(y, x);
11890
11925
  }
11891
- handleChar(char) {
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) {
11901
- this.handleScreenInput(char, this.keymap.sequences.get(char));
11926
+ /**
11927
+ * The composer/Surface body — the switch and the tail the ladder always had.
11928
+ *
11929
+ * It is shared by two adapters on purpose: its `dialog` guards *are* the Surface's
11930
+ * behaviour for those keys (a borrowed field takes them, a field-less dialog refuses
11931
+ * them), and splitting them is W-3's job. What the Router guarantees is that a Screen
11932
+ * never reaches it, so every key below can only spend itself on a field the reader
11933
+ * has in front of them — the composer's or a borrowed one. The unreachable second
11934
+ * Screen gate the old body carried is gone with it (G-6).
11935
+ */
11936
+ composerUnit(unit, moved) {
11937
+ void moved;
11938
+ // A run of text is split and re-judged per code point; a single one — ASCII, CJK, or
11939
+ // an astral character the Reader framed as one unit — falls through to the switch and
11940
+ // its owner. Counting UTF-16 code units here sent `🚀` back into this method forever
11941
+ // (W-3b.1b), which is why the test is by code point.
11942
+ if (carriesMultipleCodePoints(unit)) {
11943
+ this.handlePlainText(unit);
11902
11944
  return;
11903
11945
  }
11904
- switch (char) {
11946
+ switch (unit) {
11905
11947
  case '\x1b':
11906
11948
  this.handleEscape();
11907
11949
  return;
@@ -11924,16 +11966,11 @@ export class SshTui {
11924
11966
  // exit semantics of its own, and falling through to the process exit left a
11925
11967
  // dialog on screen killing the session it was waiting for.
11926
11968
  case '\x04':
11927
- if (this.dialog === undefined && this.screen === undefined)
11969
+ if (this.dialog === undefined)
11928
11970
  void this.requestExit(0);
11929
11971
  return;
11930
11972
  case '\x0c':
11931
- this.lastPaintRows = [];
11932
- this.lastChromeKey = '';
11933
- this.lastPaintWidth = 0;
11934
- this.lastPaintHeight = 0;
11935
- this.dirty = true;
11936
- this.render();
11973
+ this.repaintNow();
11937
11974
  return;
11938
11975
  case '\x01':
11939
11976
  if (this.textIsEditable())
@@ -11965,11 +12002,11 @@ export class SshTui {
11965
12002
  // under it changed what a later copy would take while the screen showed
11966
12003
  // nothing of the sort.
11967
12004
  case '\x0e':
11968
- if (this.dialog === undefined && this.screen === undefined)
12005
+ if (this.dialog === undefined)
11969
12006
  this.moveFocus(1);
11970
12007
  return;
11971
12008
  case '\x10':
11972
- if (this.dialog === undefined && this.screen === undefined)
12009
+ if (this.dialog === undefined)
11973
12010
  this.moveFocus(-1);
11974
12011
  return;
11975
12012
  case '\x12':
@@ -11986,33 +12023,21 @@ export class SshTui {
11986
12023
  this.markDirty();
11987
12024
  return;
11988
12025
  }
11989
- // A Screen owns the keyboard here too. This is the path a plain character
11990
- // takes (`handleChar`), and the Screen branch must come first or a `y` typed at
11991
- // a Screen's confirmation would fall through to the composer behind it.
11992
- if (this.screen !== undefined) {
11993
- this.handleScreenInput(char, this.keymap.sequences.get(char));
11994
- return;
11995
- }
11996
12026
  if (this.dialog !== undefined) {
11997
- this.handleDialogChar(char);
12027
+ this.handleDialogChar(unit);
11998
12028
  return;
11999
12029
  }
12000
- if (char === '\x07') {
12001
- if (this.dialog === undefined)
12002
- this.stepSearch(1);
12030
+ if (unit === '\x07') {
12031
+ this.stepSearch(1);
12003
12032
  return;
12004
12033
  }
12005
- if (char === '\x1f') {
12006
- if (this.dialog !== undefined)
12007
- return;
12034
+ if (unit === '\x1f') {
12008
12035
  this.input = '/find ';
12009
12036
  this.cursor = this.input.length;
12010
12037
  this.markDirty();
12011
12038
  return;
12012
12039
  }
12013
- if (char === '\t') {
12014
- if (this.dialog !== undefined)
12015
- return;
12040
+ if (unit === '\t') {
12016
12041
  if (this.suggestionsVisible()) {
12017
12042
  const selected = this.commandSuggestions[this.suggestionIndex];
12018
12043
  if (selected !== undefined) {
@@ -12029,14 +12054,154 @@ export class SshTui {
12029
12054
  this.markDirty();
12030
12055
  return;
12031
12056
  }
12032
- if (char >= ' ' && char !== '\x7f') {
12057
+ if (unit >= ' ' && unit !== '\x7f') {
12033
12058
  if (!this.textIsEditable())
12034
12059
  return;
12035
12060
  this.leaveHistoryBrowse();
12036
- this.insertIntoField(char);
12061
+ this.insertIntoField(unit);
12037
12062
  this.markDirty();
12038
12063
  }
12039
12064
  }
12065
+ /**
12066
+ * Ctrl+L's repaint, in one place for the owners that share it (G-6).
12067
+ *
12068
+ * The Screen has its own implementation (`screenNeedsFullPaint`, because it repaints
12069
+ * a Screen's frame); this one drops the cached rows so the next full frame is rebuilt
12070
+ * from scratch. W-2 does not merge them — only a test that proves both owners repaint
12071
+ * identically may (AD-20 §6) — but it does give the composer's copy a name.
12072
+ */
12073
+ repaintNow() {
12074
+ this.lastPaintRows = [];
12075
+ this.lastChromeKey = '';
12076
+ this.lastPaintWidth = 0;
12077
+ this.lastPaintHeight = 0;
12078
+ this.dirty = true;
12079
+ this.render();
12080
+ }
12081
+ /**
12082
+ * Insert pasted text into the field that owns the keyboard; CR/LF are literal
12083
+ * newlines.
12084
+ *
12085
+ * A paste is text input, so it goes through the same ownership question ordinary
12086
+ * typing does: into the composer's draft when the composer owns the keyboard, and
12087
+ * into a borrowed field when a surface that has one does. A surface without a
12088
+ * text field (a confirmation, a menu, a report) takes no paste at all — the
12089
+ * alternative was writing the composer's hidden draft behind it, which is how a
12090
+ * paste into an approval dialog could silently rewrite the message being written.
12091
+ *
12092
+ * A **Screen** is that same question with a different answer, and it is asked
12093
+ * first because `textIsEditable()` only ever answers for the composer's field: the
12094
+ * setup Screen keeps a field of its own, which the composer's helpers cannot see
12095
+ * and must never be handed (B2.6 §4).
12096
+ */
12097
+ handlePasteText(text) {
12098
+ const normalized = text.replaceAll('\r\n', '\n').replaceAll('\r', '\n');
12099
+ if (normalized === '')
12100
+ return;
12101
+ if (this.screen !== undefined) {
12102
+ this.pasteIntoScreen(normalized);
12103
+ return;
12104
+ }
12105
+ if (!this.textIsEditable())
12106
+ return;
12107
+ this.leaveHistoryBrowse();
12108
+ const current = this.fieldText();
12109
+ const at = this.fieldCursor();
12110
+ this.setField(`${current.slice(0, at)}${normalized}${current.slice(at)}`, at + normalized.length);
12111
+ if (this.borrowedText === undefined) {
12112
+ const cols = Math.max(10, this.screenColumns());
12113
+ const lineWidth = Math.max(1, cols - 2);
12114
+ if (normalized.includes('\n') || displayWidth(this.fieldText()) > lineWidth)
12115
+ this.inputFolded = true;
12116
+ }
12117
+ this.markDirty();
12118
+ }
12119
+ /**
12120
+ * Give a paste to the field the Screen owns, or to nobody.
12121
+ *
12122
+ * The setup Screen is the only Screen with a field, and it is the one place a
12123
+ * paste matters most: an API key, a base URL and a model list are all copied from
12124
+ * somewhere else, and a first run that could only be typed by hand is the
12125
+ * difference between a working session and an empty one. A step without a field —
12126
+ * the model pickers, the confirmation — is not a text field, so the paste is
12127
+ * dropped exactly as a menu drops it: a paste is text, never an answer.
12128
+ */
12129
+ pasteIntoScreen(text) {
12130
+ const state = this.onboarding;
12131
+ if (this.screen?.kind !== 'setup' || state === undefined || state.saving)
12132
+ return;
12133
+ if (!SETUP_FIELD_STEPS.has(state.step))
12134
+ return;
12135
+ // The field is drawn on one row, so it takes the value and not the line breaks
12136
+ // around it: a key copied off a web page carries the newline that ended its
12137
+ // line, and a pasted list or `.env` block means the breaks as separators. Both
12138
+ // are folded here rather than inserted literally, which would leave the field
12139
+ // holding text no step can parse and no row can draw.
12140
+ const flat = text.split('\n').map(part => part.trim()).filter(part => part !== '').join(' ');
12141
+ if (flat === '')
12142
+ return;
12143
+ this.setupInsert(state, flat);
12144
+ this.markDirty();
12145
+ }
12146
+ /**
12147
+ * Whether the keys that edit text belong to a field right now.
12148
+ *
12149
+ * The composer always has one; an open surface only has one when it borrowed it
12150
+ * (a free-text answer). Everything else — a confirmation, a menu, the inspect
12151
+ * overlay, every Screen — owns the keyboard without owning the composer's text,
12152
+ * and those keys must do nothing rather than reach the draft behind the surface.
12153
+ * A Screen is included by name: the setup Screen has a field of its own, and it
12154
+ * is reached through the Screen's own keys, never through the composer's.
12155
+ */
12156
+ textIsEditable() {
12157
+ if (this.screen !== undefined)
12158
+ return false;
12159
+ return this.dialog === undefined || this.borrowedText !== undefined;
12160
+ }
12161
+ handlePlainText(text) {
12162
+ // Fallback for terminals without bracketed paste: a burst of multiple line
12163
+ // breaks in one chunk is a paste, not repeated Enter presses.
12164
+ if (InputReader.looksLikePasteBurst(text)) {
12165
+ this.dispatchPaste(text);
12166
+ return;
12167
+ }
12168
+ // A text unit is delivered one character at a time and each character is routed
12169
+ // again — that is what lets ownership change inside one unit (a Surface answers `y`
12170
+ // and the composer takes the `es` behind it, W-2). A Screen is the one owner that
12171
+ // does not work that way: it owns the read it was sent, and the read-level guard in
12172
+ // `handleInputText` drops the units after it closes. This is the same rule one level
12173
+ // down, for the units that carry several characters: without it a character that
12174
+ // closes the Screen — the `ESC` of a paste tail, a `q`, an Enter — ended the Screen
12175
+ // and the rest of the same unit was typed into the composer behind it (W-3a.1).
12176
+ const dismissals = this.screenDismissals;
12177
+ let previous = '';
12178
+ for (const char of text) {
12179
+ if (this.screenDismissals !== dismissals)
12180
+ return;
12181
+ // Windows terminals may deliver Enter as CRLF; consume only the first half.
12182
+ if (char === '\n' && previous === '\r') {
12183
+ previous = char;
12184
+ continue;
12185
+ }
12186
+ if (char === '\r' && previous === '\n') {
12187
+ previous = char;
12188
+ continue;
12189
+ }
12190
+ this.handleChar(char);
12191
+ previous = char;
12192
+ }
12193
+ }
12194
+ /**
12195
+ * One character, from a read or from the typing loop: the Router decides, again.
12196
+ *
12197
+ * It is asked per character rather than per read because ownership can change inside
12198
+ * one read — a Surface that answers the first character of `yes` is gone for the rest
12199
+ * (pinned by the `yes-typed-at-dialog` trace). A Screen never reaches the body below:
12200
+ * the Router answered `screen` and the Screen adapter took the character.
12201
+ */
12202
+ handleChar(char) {
12203
+ this.dispatch(this.charKind(char), char, this.keymap.sequences.get(char));
12204
+ }
12040
12205
  /**
12041
12206
  * Run one rebindable action.
12042
12207
  * @param action - the action the pressed key was moved to.
@@ -12044,11 +12209,14 @@ export class SshTui {
12044
12209
  */
12045
12210
  runKeyAction(action) {
12046
12211
  switch (action) {
12212
+ // Only reachable with nothing layered over the workspace (the ladder asks this
12213
+ // table first thing, and a Screen returns before it), so the target is the
12214
+ // transcript — named here rather than re-derived inside a shared scroller.
12047
12215
  case 'pageUp':
12048
- this.scrollActiveSurface(Math.max(3, Math.floor(this.screenRows() / 2)));
12216
+ this.scrollWorkspace(Math.max(3, Math.floor(this.screenRows() / 2)));
12049
12217
  return true;
12050
12218
  case 'pageDown':
12051
- this.scrollActiveSurface(-Math.max(3, Math.floor(this.screenRows() / 2)));
12219
+ this.scrollWorkspace(-Math.max(3, Math.floor(this.screenRows() / 2)));
12052
12220
  return true;
12053
12221
  case 'toggleCard':
12054
12222
  this.toggleCollapsible();
@@ -13452,10 +13620,14 @@ export class SshTui {
13452
13620
  // *what* went to the clipboard, and a second press of the copy key must be
13453
13621
  // idempotent. Clearing it here made the next `/copy` silently fall back to
13454
13622
  // "the latest reply" — a different row, with nothing on screen to say so.
13455
- this.input = '';
13456
- this.cursor = 0;
13457
- this.inputFolded = false;
13458
- this.leaveHistoryBrowse();
13623
+ //
13624
+ // **The composer's draft is not this function's to write** (input-ownership
13625
+ // AD-20, F-2). It used to be: `this.input = ''` + caret + fold + the history
13626
+ // browse reset sat here from the 0.5.5 era, when `/copy` was *text submitted
13627
+ // from the composer* and clearing it was housekeeping. The command line is
13628
+ // `runCommand`'s to consume (it clears the composer for every command), and the
13629
+ // key, a drag, a link click and a Screen's copy are not commands at all — a
13630
+ // reader who typed half a message and then copied a reply lost it.
13459
13631
  this.write(osc52Clipboard(text));
13460
13632
  this.pushRow(represent('copy-feedback', { kind: 'system', text: notice }));
13461
13633
  // The write is a request the terminal may ignore: VTE (GNOME/XFCE/…) has
@@ -13592,13 +13764,7 @@ export class SshTui {
13592
13764
  * inspect overlay owns the keyboard and takes no scroll at all: the transcript
13593
13765
  * behind a dialog must not move.
13594
13766
  */
13595
- scrollActiveSurface(up) {
13596
- if (this.screen !== undefined) {
13597
- this.scrollScreen(up);
13598
- return;
13599
- }
13600
- if (this.dialog !== undefined)
13601
- return;
13767
+ scrollWorkspace(up) {
13602
13768
  if (this.paintTailBudget > 0) {
13603
13769
  this.paintTailBudget = 0;
13604
13770
  this.forceFullPaint = true;
@@ -13987,7 +14153,7 @@ export class SshTui {
13987
14153
  break;
13988
14154
  }
13989
14155
  const targets = ids.map(id => SessionId(id));
13990
- void subagents.drainContinuableChildren(this.agent, targets).then(() => {
14156
+ void drainChildSubagents(subagents, this.agent, targets).then(() => {
13991
14157
  this.pushRow(represent('subagent-notice', { kind: 'system', text: t('sub.killRequested', { ids: ids.join(', ') }) }));
13992
14158
  this.markDirty();
13993
14159
  }).catch((error) => {
@@ -14167,13 +14333,21 @@ export class SshTui {
14167
14333
  this.inputFolded = false;
14168
14334
  this.markDirty();
14169
14335
  }
14170
- /** Grapheme cluster immediately before `cursor`; cursor-internal positions delete it whole. */
14336
+ /**
14337
+ * Grapheme cluster immediately before `cursor`; cursor-internal positions delete it whole.
14338
+ *
14339
+ * The cluster is measured in the field that owns the keyboard, like `backspace` and
14340
+ * `deleteAtCursor` above, which is what makes their ranges mean the same thing as the
14341
+ * cursor they were handed (W-3b.1b): segmenting the *draft* while editing a borrowed
14342
+ * answer gave offsets from another string, so a borrowed `a🚀` lost one surrogate and
14343
+ * kept the caret inside the pair.
14344
+ */
14171
14345
  graphemeBefore(cursor) {
14172
14346
  if (cursor <= 0)
14173
14347
  return { start: 0, end: 0 };
14174
14348
  let previousStart = 0;
14175
14349
  let previousEnd = 0;
14176
- for (const segment of GRAPHEME_SEGMENTER.segment(this.input)) {
14350
+ for (const segment of GRAPHEME_SEGMENTER.segment(this.fieldText())) {
14177
14351
  const start = segment.index;
14178
14352
  const end = start + segment.segment.length;
14179
14353
  if (cursor === start)
@@ -14185,9 +14359,9 @@ export class SshTui {
14185
14359
  }
14186
14360
  return { start: previousStart, end: previousEnd };
14187
14361
  }
14188
- /** Grapheme cluster containing or following `cursor`. */
14362
+ /** Grapheme cluster containing or following `cursor`, in the field that owns the keyboard. */
14189
14363
  graphemeAfter(cursor) {
14190
- for (const segment of GRAPHEME_SEGMENTER.segment(this.input)) {
14364
+ for (const segment of GRAPHEME_SEGMENTER.segment(this.fieldText())) {
14191
14365
  const start = segment.index;
14192
14366
  const end = start + segment.segment.length;
14193
14367
  if (cursor === start || (cursor > start && cursor < end))
@@ -14269,8 +14443,19 @@ export class SshTui {
14269
14443
  this.cursor = this.input.length;
14270
14444
  this.markDirty();
14271
14445
  }
14272
- /** Typing while browsing history detaches from the saved item. */
14446
+ /**
14447
+ * Typing while browsing history detaches from the saved item.
14448
+ *
14449
+ * The browse is the **composer's**, so only the composer's own field may end it
14450
+ * (W-3b.3a). Every editing key a Surface borrowed the field for arrives here —
14451
+ * `backspace`, `deleteAtCursor`, `handlePasteText`, and `composerUnit`'s Ctrl+U /
14452
+ * Ctrl+K — and detaching on that path wrote the hidden composer's state from the
14453
+ * visible answer: the recalled entry replaced `historyDraft`, so the unsent draft
14454
+ * the browse was holding could never be reached again with `↓`.
14455
+ */
14273
14456
  leaveHistoryBrowse() {
14457
+ if (this.borrowedText !== undefined)
14458
+ return;
14274
14459
  if (this.historyIndex >= 0 && this.historyIndex < this.history.length) {
14275
14460
  this.historyIndex = this.history.length;
14276
14461
  this.historyDraft = this.input;