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/CHANGELOG.md +92 -0
- package/README.en.md +17 -15
- package/README.md +11 -8
- package/docs/release-notes-0.8.2.md +3 -1
- package/docs/release-notes-0.8.3.md +72 -0
- package/docs/release-notes-0.8.4-rc.1.md +271 -0
- package/lib/dsh-compat.js +22 -0
- package/lib/dsh-compat.js.map +1 -1
- package/lib/footer-budget.js +1 -7
- package/lib/footer-budget.js.map +1 -1
- package/lib/input-reader.js +415 -0
- package/lib/input-reader.js.map +1 -0
- package/lib/input-router.js +82 -0
- package/lib/input-router.js.map +1 -0
- package/lib/terminal-input.js +14 -10
- package/lib/terminal-input.js.map +1 -1
- package/lib/tui.js +711 -354
- package/lib/tui.js.map +1 -1
- package/lib/types/dsh-compat.d.ts +20 -1
- package/lib/types/footer-budget.d.ts +0 -9
- package/lib/types/input-reader.d.ts +198 -0
- package/lib/types/input-router.d.ts +73 -0
- package/lib/types/terminal-input.d.ts +15 -1
- package/lib/types/tui.d.ts +210 -18
- package/package.json +88 -87
- package/lib/footer-accents.js +0 -28
- package/lib/footer-accents.js.map +0 -1
- package/lib/types/footer-accents.d.ts +0 -22
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,
|
|
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
|
-
*
|
|
1441
|
-
*
|
|
1442
|
-
*
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
//
|
|
5750
|
-
//
|
|
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
|
-
:
|
|
5754
|
-
?
|
|
5755
|
-
:
|
|
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
|
-
|
|
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.
|
|
11419
|
-
|
|
11420
|
-
|
|
11421
|
-
|
|
11422
|
-
|
|
11423
|
-
|
|
11424
|
-
//
|
|
11425
|
-
//
|
|
11426
|
-
//
|
|
11427
|
-
|
|
11428
|
-
|
|
11429
|
-
|
|
11430
|
-
|
|
11431
|
-
//
|
|
11432
|
-
//
|
|
11433
|
-
//
|
|
11434
|
-
|
|
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
|
-
|
|
11437
|
-
|
|
11438
|
-
|
|
11439
|
-
//
|
|
11440
|
-
|
|
11441
|
-
|
|
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
|
-
|
|
11453
|
-
|
|
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
|
-
|
|
11559
|
-
|
|
11560
|
-
|
|
11561
|
-
|
|
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
|
-
|
|
11580
|
-
|
|
11581
|
-
|
|
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.
|
|
11636
|
-
|
|
11637
|
-
|
|
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.
|
|
11653
|
+
this.dispatchWith(owner, combined, moved);
|
|
11645
11654
|
}
|
|
11646
|
-
/**
|
|
11647
|
-
|
|
11648
|
-
|
|
11649
|
-
|
|
11650
|
-
|
|
11651
|
-
|
|
11652
|
-
|
|
11653
|
-
|
|
11654
|
-
|
|
11655
|
-
|
|
11656
|
-
|
|
11657
|
-
|
|
11658
|
-
|
|
11659
|
-
|
|
11660
|
-
|
|
11661
|
-
|
|
11662
|
-
|
|
11663
|
-
|
|
11664
|
-
|
|
11665
|
-
|
|
11666
|
-
|
|
11667
|
-
|
|
11668
|
-
|
|
11669
|
-
|
|
11670
|
-
|
|
11671
|
-
|
|
11672
|
-
|
|
11673
|
-
|
|
11674
|
-
|
|
11675
|
-
|
|
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
|
-
*
|
|
11681
|
-
* newlines.
|
|
11726
|
+
* A paste payload: the Router decides, the paste path executes.
|
|
11682
11727
|
*
|
|
11683
|
-
* A paste is text
|
|
11684
|
-
*
|
|
11685
|
-
*
|
|
11686
|
-
*
|
|
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
|
-
|
|
11691
|
-
|
|
11692
|
-
if (
|
|
11733
|
+
dispatchPaste(text) {
|
|
11734
|
+
this.observe('paste', text);
|
|
11735
|
+
if (this.router.route('paste', text, this.ownershipView) === 'workspace')
|
|
11693
11736
|
return;
|
|
11694
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
11801
|
+
* The Composer adapter.
|
|
11710
11802
|
*
|
|
11711
|
-
*
|
|
11712
|
-
*
|
|
11713
|
-
*
|
|
11714
|
-
*
|
|
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
|
-
|
|
11717
|
-
|
|
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
|
-
|
|
11720
|
-
|
|
11721
|
-
|
|
11722
|
-
|
|
11723
|
-
|
|
11724
|
-
|
|
11725
|
-
|
|
11726
|
-
|
|
11727
|
-
|
|
11728
|
-
|
|
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
|
-
|
|
11731
|
-
|
|
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
|
-
|
|
11735
|
-
|
|
11736
|
-
|
|
11737
|
-
|
|
11738
|
-
|
|
11739
|
-
|
|
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 (
|
|
11742
|
-
|
|
11743
|
-
|
|
11916
|
+
if (button === 0 || button === 4) {
|
|
11917
|
+
this.beginMouseSelection(y, x);
|
|
11918
|
+
return;
|
|
11744
11919
|
}
|
|
11745
|
-
|
|
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
|
-
|
|
11750
|
-
|
|
11751
|
-
|
|
11752
|
-
|
|
11753
|
-
|
|
11754
|
-
|
|
11755
|
-
|
|
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 (
|
|
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
|
|
11969
|
+
if (this.dialog === undefined)
|
|
11782
11970
|
void this.requestExit(0);
|
|
11783
11971
|
return;
|
|
11784
11972
|
case '\x0c':
|
|
11785
|
-
this.
|
|
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
|
|
12005
|
+
if (this.dialog === undefined)
|
|
11823
12006
|
this.moveFocus(1);
|
|
11824
12007
|
return;
|
|
11825
12008
|
case '\x10':
|
|
11826
|
-
if (this.dialog === 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(
|
|
12027
|
+
this.handleDialogChar(unit);
|
|
11852
12028
|
return;
|
|
11853
12029
|
}
|
|
11854
|
-
if (
|
|
11855
|
-
|
|
11856
|
-
this.stepSearch(1);
|
|
12030
|
+
if (unit === '\x07') {
|
|
12031
|
+
this.stepSearch(1);
|
|
11857
12032
|
return;
|
|
11858
12033
|
}
|
|
11859
|
-
if (
|
|
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 (
|
|
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 (
|
|
12057
|
+
if (unit >= ' ' && unit !== '\x7f') {
|
|
11887
12058
|
if (!this.textIsEditable())
|
|
11888
12059
|
return;
|
|
11889
12060
|
this.leaveHistoryBrowse();
|
|
11890
|
-
this.insertIntoField(
|
|
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.
|
|
12216
|
+
this.scrollWorkspace(Math.max(3, Math.floor(this.screenRows() / 2)));
|
|
11903
12217
|
return true;
|
|
11904
12218
|
case 'pageDown':
|
|
11905
|
-
this.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
13284
|
-
this
|
|
13285
|
-
this.
|
|
13286
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
/**
|
|
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.
|
|
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.
|
|
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
|
-
/**
|
|
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;
|