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