dsh-ssh-tui 0.8.2 → 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,6 +448,24 @@ 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
+ /** SGR mouse report: `\x1b[<button;column;row` + `M` (press/motion) or `m` (release). */
452
+ const SGR_MOUSE_RE = /^\x1b\[<(\d+);(\d+);(\d+)([Mm])$/u;
453
+ /**
454
+ * Whether one unit carries more than one Unicode code point.
455
+ *
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.
463
+ */
464
+ function carriesMultipleCodePoints(unit) {
465
+ if (unit.length <= 1)
466
+ return false;
467
+ return unit.length > ((unit.codePointAt(0) ?? 0) > 0xffff ? 2 : 1);
468
+ }
451
469
  const SETUP_STEPS = ['provider', 'id', 'base-url', 'key', 'models', 'models-pick', 'model-default', 'context', 'confirm'];
452
470
  /** The steps that show a text field (the others are pickers or the confirmation). */
453
471
  const SETUP_FIELD_STEPS = new Set(['provider', 'id', 'base-url', 'key', 'models', 'context']);
@@ -807,7 +825,6 @@ export class SshTui {
807
825
  input = '';
808
826
  cursor = 0;
809
827
  inputFolded = false;
810
- inPaste = false;
811
828
  history = [];
812
829
  historyIndex = -1;
813
830
  /** Live input parked while browsing history with ↑. Restored by ↓ past the newest item. */
@@ -1061,7 +1078,6 @@ export class SshTui {
1061
1078
  onReattach;
1062
1079
  onDetach;
1063
1080
  renderTimer;
1064
- decoder = new StringDecoder('utf8');
1065
1081
  color;
1066
1082
  maxToolOutputLines;
1067
1083
  showReasoning;
@@ -1421,8 +1437,6 @@ export class SshTui {
1421
1437
  * `turn/start` is the only thing that resets it.
1422
1438
  */
1423
1439
  reasoningExpandedChoice;
1424
- escapeBuffer = '';
1425
- escapeTimer;
1426
1440
  /**
1427
1441
  * How long an acknowledgement or a warning stays on the chrome.
1428
1442
  *
@@ -1437,11 +1451,28 @@ export class SshTui {
1437
1451
  feedbackTtlMs = resolveFeedbackTtlMs(process.env);
1438
1452
  feedbackTimer;
1439
1453
  /**
1440
- * Cursor-position replies removed from the relay's stdin stream. A launcher
1441
- * from an older release (or a reply that raced its own probe) would otherwise
1442
- * 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.
1457
+ */
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).
1443
1467
  */
1444
- inputGuard = new TerminalInputGuard(text => this.handleInputText(text));
1468
+ router = new InputRouter();
1469
+ ownershipView = {
1470
+ screenKind: undefined,
1471
+ surfaceKind: undefined,
1472
+ surfaceHasField: false,
1473
+ composerEmpty: true,
1474
+ workspaceCards: false,
1475
+ };
1445
1476
  thinkingStartedAt;
1446
1477
  waitStartedAt;
1447
1478
  /**
@@ -1471,6 +1502,15 @@ export class SshTui {
1471
1502
  cwdChipRow;
1472
1503
  /** Set when a card expand/collapse moves chrome; next paint full-redraws. */
1473
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;
1474
1514
  /** Highest row the last budgeted frame could not paint, resumed next tick. */
1475
1515
  paintResume;
1476
1516
  /** How many times a display has come back after a drop in this Host's life. */
@@ -2334,12 +2374,9 @@ export class SshTui {
2334
2374
  if (this.renderTimer !== undefined)
2335
2375
  clearInterval(this.renderTimer);
2336
2376
  this.renderTimer = undefined;
2337
- if (this.escapeTimer !== undefined)
2338
- clearTimeout(this.escapeTimer);
2339
2377
  if (this.feedbackTimer !== undefined)
2340
2378
  clearTimeout(this.feedbackTimer);
2341
- this.escapeTimer = undefined;
2342
- this.inputGuard.stop();
2379
+ this.reader.stop();
2343
2380
  process.stdin.removeListener('data', this.handleData);
2344
2381
  process.stdout.removeListener('resize', this.onDirectResize);
2345
2382
  process.stdout.removeListener('error', this.handleIoError);
@@ -3979,6 +4016,7 @@ export class SshTui {
3979
4016
  if (this.screen === undefined)
3980
4017
  return;
3981
4018
  this.screen = undefined;
4019
+ this.screenDismissals += 1;
3982
4020
  this.screenSurface = undefined;
3983
4021
  this.screenSurfaceResolve = undefined;
3984
4022
  // One full repaint when a Screen closes, and it is deliberate: the transcript
@@ -5563,7 +5601,12 @@ export class SshTui {
5563
5601
  column = Math.min(width, promptWidth + inputView.cursorOffset + 1);
5564
5602
  }
5565
5603
  else {
5566
- 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);
5567
5610
  while (pos.row >= inputDisplayLines.length)
5568
5611
  inputDisplayLines.push('');
5569
5612
  cursorRowOffset = pos.row;
@@ -5726,38 +5769,38 @@ export class SshTui {
5726
5769
  ...(health === undefined ? {} : { warning: { long: health.long, short: health.short } }),
5727
5770
  depth: this.colorDepth,
5728
5771
  color: this.color,
5729
- ...(this.footerEcho === undefined ? {} : { echo: this.footerEcho.text }),
5730
5772
  };
5731
5773
  const stripText = runtimeStrip(stripInput, Math.max(1, width), this.mutedSeparator(), this.spinnerFrame());
5732
- // The acknowledgement the strip could not fit.
5733
- //
5734
- // The echo chip is the lowest-priority group on the stats row, which is exactly
5735
- // the state a working session is in — so a command's confirmation was dropped
5736
- // entirely and the reader saw *no* feedback at all (B2.5, reported). The chip is
5737
- // still where it goes first; when it did not survive, the row that always fits
5738
- // says it instead. One message, one place, no extra rows.
5739
- const missingEcho = this.notice === undefined
5740
- && this.footerEcho !== undefined
5741
- && !stripText.includes(this.footerEcho.text)
5742
- ? this.footerEcho.text
5743
- : undefined;
5744
5774
  // The row is one muted run with the accents spliced in at higher intensity;
5745
5775
  // `muteFooterLine` reopens the mute after every reset so the (already
5746
5776
  // coloured) pips and meters cannot bleed into the text around them.
5747
5777
  const statsLine = clipAnsiToWidth(this.muteFooterLine(stripText), Math.max(1, width));
5748
5778
  this.healthChipRow = undefined;
5749
- // The identity row is composed *after* the strip, because whether it needs to
5750
- // 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;
5751
5794
  const statusText = this.notice !== undefined
5752
5795
  ? truncateToWidth(this.notice.text, Math.max(1, width))
5753
- : missingEcho === undefined
5754
- ? fitFooterStatusLine('', identity, Math.max(1, width))
5755
- : truncateToWidth(missingEcho, Math.max(1, width));
5796
+ : echoText !== undefined
5797
+ ? truncateToWidth(echoText, Math.max(1, width))
5798
+ : fitFooterStatusLine('', identity, Math.max(1, width));
5756
5799
  // Accents on an otherwise muted line: the `sub:` chip. Each reset reopens
5757
5800
  // mute, the same way the pre-strip footer did. `styleLine` sanitises first,
5758
5801
  // so the accent is spliced in after.
5759
5802
  let statusLine = this.styleLine('system', statusText);
5760
- if (this.color && this.notice === undefined) {
5803
+ if (this.color && this.notice === undefined && echoText === undefined) {
5761
5804
  // The chip is only accented when it survived the fit: a `sub:` route the
5762
5805
  // row could not afford is not on the line, and `paintFooterSubagentChip`
5763
5806
  // returns the line unchanged when it cannot find its text.
@@ -8727,6 +8770,21 @@ export class SshTui {
8727
8770
  const at = this.fieldCursor();
8728
8771
  this.setField(`${current.slice(0, at)}${text}${current.slice(at)}`, at + text.length);
8729
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
+ }
8730
8788
  /** One card's key: a call's batch can repeat a question id across calls. */
8731
8789
  questionCardKey(callId, questionId) {
8732
8790
  return `${callId ?? `live:${this.liveQuestionSeq}`}\u0000${questionId}`;
@@ -11253,10 +11311,7 @@ export class SshTui {
11253
11311
  }
11254
11312
  // ── keyboard ────────────────────────────────────────────────────────────
11255
11313
  handleData = (chunk) => {
11256
- const decoded = this.decoder.write(chunk);
11257
- if (decoded === '')
11258
- return;
11259
- this.inputGuard.push(decoded);
11314
+ this.reader.push(chunk);
11260
11315
  };
11261
11316
  /**
11262
11317
  * One key, handled by the Screen that is up.
@@ -11271,6 +11326,24 @@ export class SshTui {
11271
11326
  const screen = this.screen;
11272
11327
  if (screen === undefined)
11273
11328
  return;
11329
+ // Ctrl+L repaints the frame and belongs to the terminal rather than to any
11330
+ // surface that happens to be up: a Screen replaces the workspace entirely, so a
11331
+ // reader with a garbled terminal would otherwise have to leave the Screen to
11332
+ // clear it. The next paint is a full one, exactly as the composer's Ctrl+L is.
11333
+ if (combined === '\x0c') {
11334
+ this.screenNeedsFullPaint = true;
11335
+ this.markDirty();
11336
+ return;
11337
+ }
11338
+ // A burst of line breaks in one read is a paste from a console that cannot say so
11339
+ // (`looksLikePasteBurst`): it belongs in the field as one edit, which is how a
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)) {
11344
+ this.pasteIntoScreen(combined);
11345
+ return;
11346
+ }
11274
11347
  // The setup Screen owns its own field, its own navigation and its own keys: it
11275
11348
  // consumes everything while it is up, and nothing reaches the workspace's
11276
11349
  // composer, history or transcript (B2.6 §4).
@@ -11414,31 +11487,86 @@ export class SshTui {
11414
11487
  screen.notice = caveat ?? notice;
11415
11488
  this.markDirty();
11416
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
+ */
11417
11494
  handleInputText(text) {
11418
- const combined = this.escapeBuffer + text;
11419
- this.escapeBuffer = '';
11420
- if (this.escapeTimer !== undefined) {
11421
- clearTimeout(this.escapeTimer);
11422
- this.escapeTimer = undefined;
11423
- }
11424
- // Bracketed paste: terminals wrap pasted content in \x1b[200~ ... \x1b[201~.
11425
- // While inside a paste, CR/LF are literal input characters rather than
11426
- // submit, so copying a multi-line error message arrives as one message.
11427
- if (this.inPaste || combined.includes('\x1b[200~') || combined.includes('\x1b[201~')) {
11428
- this.processPasteChunk(combined);
11429
- return;
11430
- }
11431
- // A key the user moved, and a key their move displaced. Consulted before any
11432
- // routing so a plain control byte reaches it as readily as a CSI sequence;
11433
- // everything else keeps the handling below, which is what makes the defaults
11434
- // 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.
11435
11562
  const moved = combined === '' ? undefined : this.keymap.sequences.get(combined);
11436
- // A Screen replaces the workspace and owns every key outright. There is no
11437
- // composer behind it, no transcript to fall through to and no Surface it could
11438
- // share with: one gate, instead of the ten `dialog?.kind === 'inspect'` checks
11439
- // this replaced (B2.1).
11440
- if (this.screen !== undefined) {
11441
- 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);
11442
11570
  return;
11443
11571
  }
11444
11572
  if (this.dialog === undefined && combined !== '') {
@@ -11449,124 +11577,14 @@ export class SshTui {
11449
11577
  if (this.dialog === undefined && this.keymap.suppressed.has(combined))
11450
11578
  return;
11451
11579
  }
11452
- const escape = /^\x1b\[([A-D])$/u;
11453
- const match = combined.match(escape);
11454
- if (match !== null) {
11455
- switch (match[1]) {
11456
- case 'A':
11457
- if (this.moveQuestionCursor(-1)) {
11458
- return;
11459
- return;
11460
- return;
11461
- }
11462
- else if (this.dialog !== undefined) {
11463
- // A surface owns the keyboard: an arrow it does not use is not the
11464
- // transcript's. Falling through moved the selection behind the dialog,
11465
- // and with a non-empty field it rewrote the field from history.
11466
- return;
11467
- }
11468
- else if (this.suggestionsVisible()) {
11469
- this.suggestionIndex = Math.max(0, this.suggestionIndex - 1);
11470
- this.markDirty();
11471
- }
11472
- else if (this.fieldText() === '' && this.collapsibleRows().length > 0) {
11473
- this.moveFocus(-1);
11474
- }
11475
- else {
11476
- this.historyBack();
11477
- }
11478
- return;
11479
- case 'B':
11480
- if (this.moveQuestionCursor(1)) {
11481
- return;
11482
- return;
11483
- return;
11484
- }
11485
- else if (this.dialog !== undefined) {
11486
- return;
11487
- }
11488
- else if (this.suggestionsVisible()) {
11489
- this.suggestionIndex = Math.min(this.commandSuggestions.length - 1, this.suggestionIndex + 1);
11490
- this.markDirty();
11491
- }
11492
- else if (this.fieldText() === '' && this.collapsibleRows().length > 0) {
11493
- this.moveFocus(1);
11494
- }
11495
- else {
11496
- this.historyForward();
11497
- }
11498
- return;
11499
- case 'C':
11500
- if (this.textIsEditable())
11501
- this.moveCursor(1);
11502
- return;
11503
- case 'D':
11504
- if (this.textIsEditable())
11505
- this.moveCursor(-1);
11506
- return;
11507
- }
11508
- }
11509
- const sgrMouse = /^\x1b\[<(\d+);(\d+);(\d+)([Mm])$/u.exec(combined);
11510
- if (sgrMouse !== null) {
11511
- const button = Number(sgrMouse[1]);
11512
- const x = Number(sgrMouse[2]);
11513
- const y = Number(sgrMouse[3]);
11514
- if (sgrMouse[4] === 'M') {
11515
- if (button === 64) {
11516
- this.scrollActiveSurface(3);
11517
- return;
11518
- }
11519
- if (button === 65) {
11520
- this.scrollActiveSurface(-3);
11521
- return;
11522
- }
11523
- // 32 is "motion with the left button held": a drag over a reply. Shift
11524
- // adds 4 to every button code, and a terminal that still forwards the
11525
- // report while Shift is down (instead of keeping it for its own
11526
- // selection) must drag exactly like an unshifted one — otherwise the
11527
- // gesture the user was told to fall back on does nothing here.
11528
- if (button === 32 || button === 36) {
11529
- this.extendMouseSelection(y, x);
11530
- return;
11531
- }
11532
- if (button === 0 || button === 4) {
11533
- this.beginMouseSelection(y, x);
11534
- return;
11535
- }
11536
- return;
11537
- }
11538
- // A release ends either a drag (copy what it covered) or a plain click.
11539
- if (button === 0 || button === 4)
11540
- this.endMouseSelection(y, x);
11541
- return;
11542
- }
11543
- if (combined === '\x1b[5~') {
11544
- this.scrollActiveSurface(Math.max(3, Math.floor(this.screenRows() / 2)));
11545
- return;
11546
- }
11547
- if (combined === '\x1b[6~') {
11548
- this.scrollActiveSurface(-Math.max(3, Math.floor(this.screenRows() / 2)));
11549
- return;
11550
- }
11551
- if (parseCursorPositionReply(combined) !== undefined)
11552
- return;
11553
- if (combined === '\x1b[H' || combined === '\x1b[1~') {
11554
- this.cursor = 0;
11555
- this.markDirty();
11580
+ if (this.reader.isDeviceReply(combined)) {
11581
+ this.consumeFrame({ kind: 'reply', sequence: combined });
11556
11582
  return;
11557
11583
  }
11558
- if (combined === '\x1b[F' || combined === '\x1b[4~') {
11559
- this.cursor = this.input.length;
11560
- this.markDirty();
11561
- return;
11562
- }
11563
- if (combined === '\x1b[3~') {
11564
- this.deleteAtCursor();
11565
- return;
11566
- }
11567
- // kitty / CSI-u Ctrl+Shift+C (codepoint 99, mods 6 = Ctrl+Shift)
11568
- if (combined === '\x1b[99;6u') {
11569
- 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);
11570
11588
  return;
11571
11589
  }
11572
11590
  const ss3 = /^\x1bO[A-Z]/u.exec(combined);
@@ -11576,30 +11594,10 @@ export class SshTui {
11576
11594
  this.handlePlainText(rest);
11577
11595
  return;
11578
11596
  }
11579
- if (isEscapePrefix(combined)) {
11580
- this.escapeBuffer = combined;
11581
- this.escapeTimer = setTimeout(() => {
11582
- this.escapeTimer = undefined;
11583
- const pending = this.escapeBuffer;
11584
- this.escapeBuffer = '';
11585
- if (pending === '\x1b') {
11586
- this.handleChar('\x1b');
11587
- }
11588
- else if (pending === '\x1bO') {
11589
- // ESC O without an SS3 final byte is an Alt+O keystroke, not a
11590
- // function key.
11591
- this.handlePlainText('O');
11592
- }
11593
- else if (pending.startsWith('\x1b[') || pending.startsWith('\x1bO')) {
11594
- // An escape sequence that never completed: consume it silently
11595
- // instead of treating its ESC byte as a cancel.
11596
- }
11597
- else if (pending !== '') {
11598
- this.handlePlainText(pending);
11599
- }
11600
- }, 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))
11601
11600
  return;
11602
- }
11603
11601
  if (combined.startsWith('\x1b[') || combined.startsWith('\x1bO')) {
11604
11602
  // Unknown escape sequence (including SS3 function keys) — consume
11605
11603
  // without side effects.
@@ -11631,131 +11629,321 @@ export class SshTui {
11631
11629
  this.stepSearch(-1);
11632
11630
  return;
11633
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`.
11634
11641
  if (alt === 'f' || alt === 'F' || alt === '/') {
11635
- this.input = '/find ';
11636
- this.cursor = this.input.length;
11637
- this.markDirty();
11642
+ if (this.textIsEditable() && this.borrowedText === undefined) {
11643
+ this.input = '/find ';
11644
+ this.cursor = this.input.length;
11645
+ this.markDirty();
11646
+ }
11638
11647
  return;
11639
11648
  }
11640
11649
  // Other Alt+<key>: ignore ESC so it does not cancel, type the remainder.
11641
11650
  this.handlePlainText(alt);
11642
11651
  return;
11643
11652
  }
11644
- this.handlePlainText(combined);
11653
+ this.dispatchWith(owner, combined, moved);
11645
11654
  }
11646
- /** Handle one data chunk that may contain bracketed-paste markers. */
11647
- processPasteChunk(combined) {
11648
- let index = 0;
11649
- while (index < combined.length) {
11650
- if (combined.startsWith('\x1b[200~', index)) {
11651
- this.inPaste = true;
11652
- index += 6;
11653
- continue;
11654
- }
11655
- if (combined.startsWith('\x1b[201~', index)) {
11656
- this.inPaste = false;
11657
- index += 6;
11658
- continue;
11659
- }
11660
- let end = index;
11661
- while (end < combined.length
11662
- && !combined.startsWith('\x1b[200~', end)
11663
- && !combined.startsWith('\x1b[201~', end)) {
11664
- end += 1;
11665
- }
11666
- if (end > index) {
11667
- const part = combined.slice(index, end);
11668
- if (this.inPaste)
11669
- this.handlePasteText(part);
11670
- else
11671
- this.handlePlainText(part);
11672
- index = end;
11673
- }
11674
- else {
11675
- index += 1;
11676
- }
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;
11677
11723
  }
11678
11724
  }
11679
11725
  /**
11680
- * Insert pasted text into the field that owns the keyboard; CR/LF are literal
11681
- * newlines.
11726
+ * A paste payload: the Router decides, the paste path executes.
11682
11727
  *
11683
- * A paste is text input, so it goes through the same ownership question ordinary
11684
- * typing does: into the composer's draft when the composer owns the keyboard, and
11685
- * 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
11687
- * alternative was writing the composer's hidden draft behind it, which is how a
11688
- * 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).
11689
11732
  */
11690
- handlePasteText(text) {
11691
- const normalized = text.replaceAll('\r\n', '\n').replaceAll('\r', '\n');
11692
- if (normalized === '')
11733
+ dispatchPaste(text) {
11734
+ this.observe('paste', text);
11735
+ if (this.router.route('paste', text, this.ownershipView) === 'workspace')
11693
11736
  return;
11694
- if (!this.textIsEditable())
11737
+ this.handlePasteText(text);
11738
+ }
11739
+ /**
11740
+ * The Screen adapter: a Screen owns the keyboard outright (AD-20 §4.3 priority 3).
11741
+ *
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.
11747
+ */
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);
11695
11767
  return;
11696
- this.leaveHistoryBrowse();
11697
- const current = this.fieldText();
11698
- const at = this.fieldCursor();
11699
- this.setField(`${current.slice(0, at)}${normalized}${current.slice(at)}`, at + normalized.length);
11700
- if (this.borrowedText === undefined) {
11701
- const cols = Math.max(10, this.screenColumns());
11702
- const lineWidth = Math.max(1, cols - 2);
11703
- if (normalized.includes('\n') || displayWidth(this.fieldText()) > lineWidth)
11704
- this.inputFolded = true;
11705
11768
  }
11706
- this.markDirty();
11769
+ if (unit === '\x1b[B') {
11770
+ this.moveQuestionCursor(1);
11771
+ return;
11772
+ }
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);
11779
+ return;
11780
+ }
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);
11707
11799
  }
11708
11800
  /**
11709
- * Whether the keys that edit text belong to a field right now.
11801
+ * The Composer adapter.
11710
11802
  *
11711
- * 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.
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).
11715
11809
  */
11716
- textIsEditable() {
11717
- return this.dialog === undefined || this.borrowedText !== undefined;
11810
+ ownerComposer(unit, moved) {
11811
+ if (unit === '\x1b[A') {
11812
+ this.arrowHistory(-1);
11813
+ return;
11814
+ }
11815
+ if (unit === '\x1b[B') {
11816
+ this.arrowHistory(1);
11817
+ return;
11818
+ }
11819
+ if (unit === '\x1b[C') {
11820
+ if (this.textIsEditable())
11821
+ this.moveCursor(1);
11822
+ return;
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);
11718
11850
  }
11719
- handlePlainText(text) {
11720
- // Fallback for terminals without bracketed paste: a burst of multiple line
11721
- // 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;
11851
+ /**
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).
11854
+ */
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;
11729
11870
  }
11730
- if (newlines > 1) {
11731
- this.handlePasteText(text);
11871
+ const page = Math.max(3, Math.floor(this.screenRows() / 2));
11872
+ if (unit === '\x1b[5~') {
11873
+ this.scrollWorkspace(page);
11732
11874
  return;
11733
11875
  }
11734
- let previous = '';
11735
- for (const char of text) {
11736
- // Windows terminals may deliver Enter as CRLF; consume only the first half.
11737
- if (char === '\n' && previous === '\r') {
11738
- previous = char;
11739
- continue;
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
+ }
11888
+ }
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();
11896
+ return;
11897
+ }
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;
11740
11915
  }
11741
- if (char === '\r' && previous === '\n') {
11742
- previous = char;
11743
- continue;
11916
+ if (button === 0 || button === 4) {
11917
+ this.beginMouseSelection(y, x);
11918
+ return;
11744
11919
  }
11745
- this.handleChar(char);
11746
- previous = char;
11920
+ return;
11747
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);
11748
11925
  }
11749
- 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') {
11755
- 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);
11756
11944
  return;
11757
11945
  }
11758
- switch (char) {
11946
+ switch (unit) {
11759
11947
  case '\x1b':
11760
11948
  this.handleEscape();
11761
11949
  return;
@@ -11778,16 +11966,11 @@ export class SshTui {
11778
11966
  // exit semantics of its own, and falling through to the process exit left a
11779
11967
  // dialog on screen killing the session it was waiting for.
11780
11968
  case '\x04':
11781
- if (this.dialog === undefined && this.screen === undefined)
11969
+ if (this.dialog === undefined)
11782
11970
  void this.requestExit(0);
11783
11971
  return;
11784
11972
  case '\x0c':
11785
- this.lastPaintRows = [];
11786
- this.lastChromeKey = '';
11787
- this.lastPaintWidth = 0;
11788
- this.lastPaintHeight = 0;
11789
- this.dirty = true;
11790
- this.render();
11973
+ this.repaintNow();
11791
11974
  return;
11792
11975
  case '\x01':
11793
11976
  if (this.textIsEditable())
@@ -11819,11 +12002,11 @@ export class SshTui {
11819
12002
  // under it changed what a later copy would take while the screen showed
11820
12003
  // nothing of the sort.
11821
12004
  case '\x0e':
11822
- if (this.dialog === undefined && this.screen === undefined)
12005
+ if (this.dialog === undefined)
11823
12006
  this.moveFocus(1);
11824
12007
  return;
11825
12008
  case '\x10':
11826
- if (this.dialog === undefined && this.screen === undefined)
12009
+ if (this.dialog === undefined)
11827
12010
  this.moveFocus(-1);
11828
12011
  return;
11829
12012
  case '\x12':
@@ -11840,33 +12023,21 @@ export class SshTui {
11840
12023
  this.markDirty();
11841
12024
  return;
11842
12025
  }
11843
- // A Screen owns the keyboard here too. This is the path a plain character
11844
- // takes (`handleChar`), and the Screen branch must come first or a `y` typed at
11845
- // a Screen's confirmation would fall through to the composer behind it.
11846
- if (this.screen !== undefined) {
11847
- this.handleScreenInput(char, this.keymap.sequences.get(char));
11848
- return;
11849
- }
11850
12026
  if (this.dialog !== undefined) {
11851
- this.handleDialogChar(char);
12027
+ this.handleDialogChar(unit);
11852
12028
  return;
11853
12029
  }
11854
- if (char === '\x07') {
11855
- if (this.dialog === undefined)
11856
- this.stepSearch(1);
12030
+ if (unit === '\x07') {
12031
+ this.stepSearch(1);
11857
12032
  return;
11858
12033
  }
11859
- if (char === '\x1f') {
11860
- if (this.dialog !== undefined)
11861
- return;
12034
+ if (unit === '\x1f') {
11862
12035
  this.input = '/find ';
11863
12036
  this.cursor = this.input.length;
11864
12037
  this.markDirty();
11865
12038
  return;
11866
12039
  }
11867
- if (char === '\t') {
11868
- if (this.dialog !== undefined)
11869
- return;
12040
+ if (unit === '\t') {
11870
12041
  if (this.suggestionsVisible()) {
11871
12042
  const selected = this.commandSuggestions[this.suggestionIndex];
11872
12043
  if (selected !== undefined) {
@@ -11883,14 +12054,154 @@ export class SshTui {
11883
12054
  this.markDirty();
11884
12055
  return;
11885
12056
  }
11886
- if (char >= ' ' && char !== '\x7f') {
12057
+ if (unit >= ' ' && unit !== '\x7f') {
11887
12058
  if (!this.textIsEditable())
11888
12059
  return;
11889
12060
  this.leaveHistoryBrowse();
11890
- this.insertIntoField(char);
12061
+ this.insertIntoField(unit);
11891
12062
  this.markDirty();
11892
12063
  }
11893
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
+ }
11894
12205
  /**
11895
12206
  * Run one rebindable action.
11896
12207
  * @param action - the action the pressed key was moved to.
@@ -11898,11 +12209,14 @@ export class SshTui {
11898
12209
  */
11899
12210
  runKeyAction(action) {
11900
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.
11901
12215
  case 'pageUp':
11902
- this.scrollActiveSurface(Math.max(3, Math.floor(this.screenRows() / 2)));
12216
+ this.scrollWorkspace(Math.max(3, Math.floor(this.screenRows() / 2)));
11903
12217
  return true;
11904
12218
  case 'pageDown':
11905
- this.scrollActiveSurface(-Math.max(3, Math.floor(this.screenRows() / 2)));
12219
+ this.scrollWorkspace(-Math.max(3, Math.floor(this.screenRows() / 2)));
11906
12220
  return true;
11907
12221
  case 'toggleCard':
11908
12222
  this.toggleCollapsible();
@@ -12249,13 +12563,6 @@ export class SshTui {
12249
12563
  this.markDirty();
12250
12564
  return;
12251
12565
  }
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
12566
  if (combined === '\x15') {
12260
12567
  // Ctrl+U clears the wizard's field, not the workspace's draft.
12261
12568
  this.setupSetField(state, '', 0);
@@ -12725,7 +13032,7 @@ export class SshTui {
12725
13032
  this.setupMessage(state, 'system', t('onboard.baseSaved', { path: displayDshPath('settings.yaml') }));
12726
13033
  }
12727
13034
  if (saved) {
12728
- this.setupMessage(state, 'system', t('onboard.officialDone', { model }));
13035
+ this.onboardDone(t('onboard.officialDone', { model }));
12729
13036
  }
12730
13037
  }
12731
13038
  else {
@@ -12898,7 +13205,7 @@ export class SshTui {
12898
13205
  this.onSelectionChanged?.(selection);
12899
13206
  await this.rememberRoute(selection);
12900
13207
  await this.syncSubagentToProvider(ownerId, state.models, previousParentProvider !== ownerId);
12901
- this.setupMessage(state, 'system', t('onboard.customDone', { id: ownerId, model }));
13208
+ this.onboardDone(t('onboard.customDone', { id: ownerId, model }));
12902
13209
  }
12903
13210
  }
12904
13211
  }
@@ -12907,12 +13214,45 @@ export class SshTui {
12907
13214
  this.setupMessage(state, 'error', t('onboard.saveFailed', { error: errorChain(error) }));
12908
13215
  }
12909
13216
  finally {
12910
- this.onboarding = undefined;
12911
13217
  state.resolve(saved);
13218
+ // The Screen's exit is the answer, and the answer is one of two things.
13219
+ //
13220
+ // **Saved**: the wizard has nothing left to ask, so it hands the workspace back
13221
+ // in the same frame. Cancelling always did this; saving did not, so a completed
13222
+ // setup left the reader on the wizard's own frame until they pressed a key —
13223
+ // and only that key's "no wizard behind this Screen" fallback closed it. Every
13224
+ // confirmation the wizard would have painted on its own row went with the
13225
+ // Screen, which is why the completion line is a transcript row (`onboardDone`).
13226
+ //
13227
+ // **Failed**: the wizard keeps the screen, because the row it just wrote is the
13228
+ // reason and the answer (an API key, a Base URL) has to stay editable to retry.
13229
+ // Dropping the state here is what used to leave a Screen with no wizard behind
13230
+ // it: the error was written and then thrown away with it, and the next keypress
13231
+ // dismissed the whole flow as if nothing had been asked.
13232
+ if (saved) {
13233
+ this.onboarding = undefined;
13234
+ this.closeScreen();
13235
+ }
13236
+ else {
13237
+ state.saving = false;
13238
+ }
12912
13239
  this.showNextDialog();
12913
13240
  this.markDirty();
12914
13241
  }
12915
13242
  }
13243
+ /**
13244
+ * Say the wizard is done, where the reader will still be able to read it.
13245
+ *
13246
+ * A saved wizard closes its Screen in the same frame, so its own notice row would
13247
+ * be painted for no frames at all — and the credential line that goes with it is a
13248
+ * footer echo (`command-feedback`), which expires. This is the durable half of the
13249
+ * answer: `command-status` routes to the transcript, exactly like the boot line
13250
+ * that asked for the setup in the first place.
13251
+ */
13252
+ onboardDone(text) {
13253
+ this.pushRow(represent('command-status', { kind: 'system', text }));
13254
+ this.markDirty();
13255
+ }
12916
13256
  /** Store one credential, falling back to a launch-environment override on shadow/absence. */
12917
13257
  async saveCredential(credentials, envRef, key) {
12918
13258
  const shadowing = process.env[envRef];
@@ -13280,10 +13620,14 @@ export class SshTui {
13280
13620
  // *what* went to the clipboard, and a second press of the copy key must be
13281
13621
  // idempotent. Clearing it here made the next `/copy` silently fall back to
13282
13622
  // "the latest reply" — a different row, with nothing on screen to say so.
13283
- this.input = '';
13284
- this.cursor = 0;
13285
- this.inputFolded = false;
13286
- 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.
13287
13631
  this.write(osc52Clipboard(text));
13288
13632
  this.pushRow(represent('copy-feedback', { kind: 'system', text: notice }));
13289
13633
  // The write is a request the terminal may ignore: VTE (GNOME/XFCE/…) has
@@ -13420,13 +13764,7 @@ export class SshTui {
13420
13764
  * inspect overlay owns the keyboard and takes no scroll at all: the transcript
13421
13765
  * behind a dialog must not move.
13422
13766
  */
13423
- scrollActiveSurface(up) {
13424
- if (this.screen !== undefined) {
13425
- this.scrollScreen(up);
13426
- return;
13427
- }
13428
- if (this.dialog !== undefined)
13429
- return;
13767
+ scrollWorkspace(up) {
13430
13768
  if (this.paintTailBudget > 0) {
13431
13769
  this.paintTailBudget = 0;
13432
13770
  this.forceFullPaint = true;
@@ -13815,7 +14153,7 @@ export class SshTui {
13815
14153
  break;
13816
14154
  }
13817
14155
  const targets = ids.map(id => SessionId(id));
13818
- void subagents.drainContinuableChildren(this.agent, targets).then(() => {
14156
+ void drainChildSubagents(subagents, this.agent, targets).then(() => {
13819
14157
  this.pushRow(represent('subagent-notice', { kind: 'system', text: t('sub.killRequested', { ids: ids.join(', ') }) }));
13820
14158
  this.markDirty();
13821
14159
  }).catch((error) => {
@@ -13995,13 +14333,21 @@ export class SshTui {
13995
14333
  this.inputFolded = false;
13996
14334
  this.markDirty();
13997
14335
  }
13998
- /** 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
+ */
13999
14345
  graphemeBefore(cursor) {
14000
14346
  if (cursor <= 0)
14001
14347
  return { start: 0, end: 0 };
14002
14348
  let previousStart = 0;
14003
14349
  let previousEnd = 0;
14004
- for (const segment of GRAPHEME_SEGMENTER.segment(this.input)) {
14350
+ for (const segment of GRAPHEME_SEGMENTER.segment(this.fieldText())) {
14005
14351
  const start = segment.index;
14006
14352
  const end = start + segment.segment.length;
14007
14353
  if (cursor === start)
@@ -14013,9 +14359,9 @@ export class SshTui {
14013
14359
  }
14014
14360
  return { start: previousStart, end: previousEnd };
14015
14361
  }
14016
- /** Grapheme cluster containing or following `cursor`. */
14362
+ /** Grapheme cluster containing or following `cursor`, in the field that owns the keyboard. */
14017
14363
  graphemeAfter(cursor) {
14018
- for (const segment of GRAPHEME_SEGMENTER.segment(this.input)) {
14364
+ for (const segment of GRAPHEME_SEGMENTER.segment(this.fieldText())) {
14019
14365
  const start = segment.index;
14020
14366
  const end = start + segment.segment.length;
14021
14367
  if (cursor === start || (cursor > start && cursor < end))
@@ -14097,8 +14443,19 @@ export class SshTui {
14097
14443
  this.cursor = this.input.length;
14098
14444
  this.markDirty();
14099
14445
  }
14100
- /** 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
+ */
14101
14456
  leaveHistoryBrowse() {
14457
+ if (this.borrowedText !== undefined)
14458
+ return;
14102
14459
  if (this.historyIndex >= 0 && this.historyIndex < this.history.length) {
14103
14460
  this.historyIndex = this.history.length;
14104
14461
  this.historyDraft = this.input;