dsh-ssh-tui 0.8.3 → 0.8.4-rc.2
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 +132 -12
- package/README.en.md +20 -17
- package/README.md +11 -8
- package/docs/release-notes-0.8.4-rc.1.md +271 -0
- package/docs/release-notes-0.8.4-rc.2.md +72 -0
- package/docs/release-notes-0.8.4.md +145 -0
- package/lib/doctor.js +41 -4
- package/lib/doctor.js.map +1 -1
- 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/i18n/en.js +6 -1
- package/lib/i18n/en.js.map +1 -1
- package/lib/i18n/zh.js +6 -1
- package/lib/i18n/zh.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/preset-rows.js +70 -19
- package/lib/preset-rows.js.map +1 -1
- package/lib/terminal-input.js +27 -13
- package/lib/terminal-input.js.map +1 -1
- package/lib/tui.js +812 -482
- package/lib/tui.js.map +1 -1
- package/lib/types/doctor.d.ts +13 -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/preset-rows.d.ts +22 -7
- 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';
|
|
@@ -47,7 +48,7 @@ import { resolveTheme, setActiveTheme, themeByName, themeEmphasisToken, themeExt
|
|
|
47
48
|
import { presetLabel, profileFromArgv } from './preset-label.js';
|
|
48
49
|
import { flattenGroups, groupPresets, optionMatches } from './preset-picker.js';
|
|
49
50
|
import { isRefusal, planCopy, planDelete, planMetadata, presetDirectory, } from './preset-authoring.js';
|
|
50
|
-
import { ensureRosterRows, planDuplicateRepair, planRosterRepair,
|
|
51
|
+
import { ensureRosterRows, planDuplicateRepair, planRosterRepair, rosterPatchPath, rosterRows, writePatchWithBackup, ALL_ROSTER_ROWS, } from './preset-rows.js';
|
|
51
52
|
import { SessionStatsTracker, sessionTokenTotal, statsRowOf, } from './stats.js';
|
|
52
53
|
import { QUESTION_OPTION_KEYS, applyQuestionFilter, backspaceQuestionFilter, clearQuestionFilter, typeQuestionFilter, visibleQuestionIndexes, confirmAnswer, inspectClosesOn, moveQuestionCursor, optionsLength, questionOptionIndex, initialQuestionSelection, questionOptionMarker, questionSubmit, selectQuestionOptionByKey, windowInteractionLines, PICKER_ROLE, interactionRole, stallsTask, surfacePriority, isSurfaceDialog, } from './dialogs.js';
|
|
53
54
|
import { commandSuggestions, localizedCommands } from './commands.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']);
|
|
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;
|
|
454
453
|
/**
|
|
455
|
-
* Whether one
|
|
454
|
+
* Whether one unit carries more than one Unicode code point.
|
|
456
455
|
*
|
|
457
|
-
*
|
|
458
|
-
*
|
|
459
|
-
*
|
|
460
|
-
*
|
|
461
|
-
*
|
|
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.
|
|
462
463
|
*/
|
|
463
|
-
function
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
if (char === '\r' && text[index + 1] !== '\n')
|
|
468
|
-
breaks += 1;
|
|
469
|
-
else if (char === '\n' && text[index - 1] !== '\r')
|
|
470
|
-
breaks += 1;
|
|
471
|
-
if (breaks > 1)
|
|
472
|
-
return true;
|
|
473
|
-
}
|
|
474
|
-
return false;
|
|
475
|
-
}
|
|
476
|
-
/**
|
|
477
|
-
* Split one read into the keys it carries.
|
|
478
|
-
*
|
|
479
|
-
* A terminal hands over whatever arrived together, which is not always one key: a
|
|
480
|
-
* fast typist on a slow link sends `text\r`, a console without bracketed paste sends
|
|
481
|
-
* a whole line, and a paste is followed by Enter inside the same read. The composer
|
|
482
|
-
* has always taken a read apart (`handlePlainText` types it character by character),
|
|
483
|
-
* and a Screen has to see the same keys — a control byte is only itself when it
|
|
484
|
-
* stands alone, so a read handed to one step is a read whose Enter is dropped on the
|
|
485
|
-
* floor. That is what this returns: escape sequences whole (an arrow is one key),
|
|
486
|
-
* control bytes one each with CRLF read as the one Enter it is, and a run of
|
|
487
|
-
* printable text kept together, because a step that filters on what it is given
|
|
488
|
-
* wants the run and a paste without markers still must not answer a confirmation.
|
|
489
|
-
*/
|
|
490
|
-
function screenKeys(text) {
|
|
491
|
-
const keys = [];
|
|
492
|
-
let index = 0;
|
|
493
|
-
while (index < text.length) {
|
|
494
|
-
const char = text[index];
|
|
495
|
-
if (char === '\x1b') {
|
|
496
|
-
// A CSI sequence (`\x1b[` params final), SS3 (`\x1bO` one char), or the lone
|
|
497
|
-
// Escape key itself when nothing follows it in this read.
|
|
498
|
-
const rest = text.slice(index);
|
|
499
|
-
const sequence = /^\x1b\[[0-9;?<]*[ -/]*[@-~]/u.exec(rest)?.[0] ?? /^\x1bO[@-~]/u.exec(rest)?.[0];
|
|
500
|
-
if (sequence !== undefined) {
|
|
501
|
-
keys.push(sequence);
|
|
502
|
-
index += sequence.length;
|
|
503
|
-
continue;
|
|
504
|
-
}
|
|
505
|
-
keys.push('\x1b');
|
|
506
|
-
index += 1;
|
|
507
|
-
continue;
|
|
508
|
-
}
|
|
509
|
-
if (char === '\r' || char === '\n') {
|
|
510
|
-
// Windows terminals send Enter as CRLF; both halves are one key.
|
|
511
|
-
const next = text[index + 1];
|
|
512
|
-
keys.push('\r');
|
|
513
|
-
index += (char === '\r' && next === '\n') || (char === '\n' && next === '\r') ? 2 : 1;
|
|
514
|
-
continue;
|
|
515
|
-
}
|
|
516
|
-
if (char < ' ' || char === '\x7f') {
|
|
517
|
-
keys.push(char);
|
|
518
|
-
index += 1;
|
|
519
|
-
continue;
|
|
520
|
-
}
|
|
521
|
-
let end = index;
|
|
522
|
-
while (end < text.length && text[end] >= ' ' && text[end] !== '\x7f')
|
|
523
|
-
end += 1;
|
|
524
|
-
keys.push(text.slice(index, end));
|
|
525
|
-
index = end;
|
|
526
|
-
}
|
|
527
|
-
return keys;
|
|
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. */
|
|
@@ -2276,6 +2241,7 @@ export class SshTui {
|
|
|
2276
2241
|
const credentials = this.ctx.get('credentials');
|
|
2277
2242
|
const provider = this.currentProviderId();
|
|
2278
2243
|
if (providerUsesLocalOAuth(provider)) {
|
|
2244
|
+
await this.repairAgentPlaneAtBoot();
|
|
2279
2245
|
// Workspace feedback *about* setup — not the wizard's own surface, which is the
|
|
2280
2246
|
// setup Screen (B2.6 §12). These rows are the command plane's.
|
|
2281
2247
|
this.pushRow(represent('command-status', {
|
|
@@ -2311,6 +2277,7 @@ export class SshTui {
|
|
|
2311
2277
|
}
|
|
2312
2278
|
if (envKey !== undefined && envKey !== '') {
|
|
2313
2279
|
if (stored || existsSync(DSH_ENV_FILE) || this.resume) {
|
|
2280
|
+
await this.repairAgentPlaneAtBoot();
|
|
2314
2281
|
this.pushRow(represent('command-status', {
|
|
2315
2282
|
kind: 'system',
|
|
2316
2283
|
text: t('onboard.envInUse', { env: envRef }),
|
|
@@ -2336,8 +2303,10 @@ export class SshTui {
|
|
|
2336
2303
|
// got an empty workspace, a provider that cannot answer, and no hint that
|
|
2337
2304
|
// `/setup` exists. The launch flag is not evidence of configuration; the
|
|
2338
2305
|
// credential store is, and that is what `stored` reads.
|
|
2339
|
-
if (stored)
|
|
2306
|
+
if (stored) {
|
|
2307
|
+
await this.repairAgentPlaneAtBoot();
|
|
2340
2308
|
return;
|
|
2309
|
+
}
|
|
2341
2310
|
this.pushRow(represent('command-status', { kind: 'system', text: t('onboard.needSetup') }));
|
|
2342
2311
|
await this.runOnboarding();
|
|
2343
2312
|
}
|
|
@@ -2409,12 +2378,9 @@ export class SshTui {
|
|
|
2409
2378
|
if (this.renderTimer !== undefined)
|
|
2410
2379
|
clearInterval(this.renderTimer);
|
|
2411
2380
|
this.renderTimer = undefined;
|
|
2412
|
-
if (this.escapeTimer !== undefined)
|
|
2413
|
-
clearTimeout(this.escapeTimer);
|
|
2414
2381
|
if (this.feedbackTimer !== undefined)
|
|
2415
2382
|
clearTimeout(this.feedbackTimer);
|
|
2416
|
-
this.
|
|
2417
|
-
this.inputGuard.stop();
|
|
2383
|
+
this.reader.stop();
|
|
2418
2384
|
process.stdin.removeListener('data', this.handleData);
|
|
2419
2385
|
process.stdout.removeListener('resize', this.onDirectResize);
|
|
2420
2386
|
process.stdout.removeListener('error', this.handleIoError);
|
|
@@ -2557,10 +2523,29 @@ export class SshTui {
|
|
|
2557
2523
|
const models = Array.isArray(providers[provider]?.models) ? providers[provider]?.models ?? [] : [];
|
|
2558
2524
|
const model = this.agent.options.model ?? this.selectionRef?.current?.model;
|
|
2559
2525
|
const sub = this.subagentSelection.current;
|
|
2526
|
+
// The official DeepSeek adapter is registered through `ctx.llm`, not through
|
|
2527
|
+
// `llm-pi-ai`; the adapter directory is the same source `src/index.ts` uses
|
|
2528
|
+
// for route availability. Keep the configured list too: a pi-ai gateway may
|
|
2529
|
+
// be configured before its adapter has finished registering. Doctor is
|
|
2530
|
+
// best-effort, so an unavailable directory falls back to that list rather
|
|
2531
|
+
// than failing the report.
|
|
2532
|
+
let adapterRoutes = [];
|
|
2533
|
+
const llm = this.ctx.get('llm');
|
|
2534
|
+
if (llm !== undefined) {
|
|
2535
|
+
try {
|
|
2536
|
+
adapterRoutes = llm.listProviders()
|
|
2537
|
+
.map(entry => entry.id.trim())
|
|
2538
|
+
.filter(id => id !== '');
|
|
2539
|
+
}
|
|
2540
|
+
catch {
|
|
2541
|
+
// Keep the configured set; `/doctor` must still render what it knows.
|
|
2542
|
+
}
|
|
2543
|
+
}
|
|
2544
|
+
const routes = [...new Set([...Object.keys(providers), ...adapterRoutes])];
|
|
2560
2545
|
return {
|
|
2561
2546
|
provider,
|
|
2562
2547
|
...(model === undefined ? {} : { model }),
|
|
2563
|
-
routes
|
|
2548
|
+
routes,
|
|
2564
2549
|
routeModels: models.map(modelEntryId).filter((id) => id !== undefined),
|
|
2565
2550
|
...(sub.provider === undefined ? {} : { subProvider: this.displayProviderId(sub.provider) }),
|
|
2566
2551
|
...(sub.model === undefined ? {} : { subModel: sub.model }),
|
|
@@ -2569,6 +2554,7 @@ export class SshTui {
|
|
|
2569
2554
|
/** One collected `/doctor` snapshot, from this process's own services. */
|
|
2570
2555
|
async collectDoctorFacts(profile) {
|
|
2571
2556
|
const routing = this.doctorRouting();
|
|
2557
|
+
const tools = this.agentPlaneTools();
|
|
2572
2558
|
return collectDoctor({
|
|
2573
2559
|
profile,
|
|
2574
2560
|
dshHome: resolveDshHome(),
|
|
@@ -2576,6 +2562,8 @@ export class SshTui {
|
|
|
2576
2562
|
services: {
|
|
2577
2563
|
roster: this.ctx.get('agentPresets') !== undefined,
|
|
2578
2564
|
codeRuntime: this.hasHostService('codeRuntime'),
|
|
2565
|
+
askUserTool: tools.askUser,
|
|
2566
|
+
presentTool: tools.present,
|
|
2579
2567
|
},
|
|
2580
2568
|
generation: hostSettingsGeneration(this.ctx),
|
|
2581
2569
|
anchors: [process.argv[1], this.ctx.baseUrl],
|
|
@@ -2658,7 +2646,7 @@ export class SshTui {
|
|
|
2658
2646
|
backup = await writePatchWithBackup(facts.patchPath, text);
|
|
2659
2647
|
}
|
|
2660
2648
|
else {
|
|
2661
|
-
await ensureRosterRows(facts.dshHome, facts.profile, rows);
|
|
2649
|
+
await ensureRosterRows(facts.dshHome, facts.profile, rows, facts.generation ?? 'legacy');
|
|
2662
2650
|
}
|
|
2663
2651
|
const done = [
|
|
2664
2652
|
t('doctor.fix.applied', {
|
|
@@ -4054,6 +4042,7 @@ export class SshTui {
|
|
|
4054
4042
|
if (this.screen === undefined)
|
|
4055
4043
|
return;
|
|
4056
4044
|
this.screen = undefined;
|
|
4045
|
+
this.screenDismissals += 1;
|
|
4057
4046
|
this.screenSurface = undefined;
|
|
4058
4047
|
this.screenSurfaceResolve = undefined;
|
|
4059
4048
|
// One full repaint when a Screen closes, and it is deliberate: the transcript
|
|
@@ -5638,7 +5627,12 @@ export class SshTui {
|
|
|
5638
5627
|
column = Math.min(width, promptWidth + inputView.cursorOffset + 1);
|
|
5639
5628
|
}
|
|
5640
5629
|
else {
|
|
5641
|
-
|
|
5630
|
+
// The caret belongs to the field being drawn, like the text above it (W-3b.1a):
|
|
5631
|
+
// `fieldCursor` is the borrowed answer's caret while a Surface has one, and the
|
|
5632
|
+
// draft's otherwise. Reading `this.cursor` here put the terminal's caret — the
|
|
5633
|
+
// cell an IME draws its pre-edit in — at the hidden draft's offset, which is a
|
|
5634
|
+
// different number whenever a Surface borrowed the field.
|
|
5635
|
+
const pos = cursorVisualPosition(inputView.text, fieldCursor, inputTextWidth);
|
|
5642
5636
|
while (pos.row >= inputDisplayLines.length)
|
|
5643
5637
|
inputDisplayLines.push('');
|
|
5644
5638
|
cursorRowOffset = pos.row;
|
|
@@ -5801,38 +5795,38 @@ export class SshTui {
|
|
|
5801
5795
|
...(health === undefined ? {} : { warning: { long: health.long, short: health.short } }),
|
|
5802
5796
|
depth: this.colorDepth,
|
|
5803
5797
|
color: this.color,
|
|
5804
|
-
...(this.footerEcho === undefined ? {} : { echo: this.footerEcho.text }),
|
|
5805
5798
|
};
|
|
5806
5799
|
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
5800
|
// The row is one muted run with the accents spliced in at higher intensity;
|
|
5820
5801
|
// `muteFooterLine` reopens the mute after every reset so the (already
|
|
5821
5802
|
// coloured) pips and meters cannot bleed into the text around them.
|
|
5822
5803
|
const statsLine = clipAnsiToWidth(this.muteFooterLine(stripText), Math.max(1, width));
|
|
5823
5804
|
this.healthChipRow = undefined;
|
|
5824
|
-
//
|
|
5825
|
-
//
|
|
5805
|
+
// Every acknowledgement lands on the identity row, and nowhere else.
|
|
5806
|
+
//
|
|
5807
|
+
// The echo used to be the lowest-priority chip *on the meter row*, with the
|
|
5808
|
+
// identity row as the fallback when it did not fit — so which of the two rows a
|
|
5809
|
+
// message appeared on depended on its length and the window's width. The same
|
|
5810
|
+
// `/copy` said `已复制 412 字…(最近回复)` on the row above the last one (it fit)
|
|
5811
|
+
// and `还没有可复制的回复…` on the last one (it did not): two acknowledgements,
|
|
5812
|
+
// two lines, nothing in the product saying why (reported). A confirmation is
|
|
5813
|
+
// read, not measured, so it is not the meter row's business either — the first
|
|
5814
|
+
// footer row answers the five telemetry questions and only those (E-1).
|
|
5815
|
+
//
|
|
5816
|
+
// A notice still outranks an echo: an operational warning must not be hidden by
|
|
5817
|
+
// an acknowledgement that happens to be alive at the same moment. Both are
|
|
5818
|
+
// Host-local, both are transient, and now both are on the row that always fits.
|
|
5819
|
+
const echoText = this.notice === undefined ? this.footerEcho?.text : undefined;
|
|
5826
5820
|
const statusText = this.notice !== undefined
|
|
5827
5821
|
? truncateToWidth(this.notice.text, Math.max(1, width))
|
|
5828
|
-
:
|
|
5829
|
-
?
|
|
5830
|
-
:
|
|
5822
|
+
: echoText !== undefined
|
|
5823
|
+
? truncateToWidth(echoText, Math.max(1, width))
|
|
5824
|
+
: fitFooterStatusLine('', identity, Math.max(1, width));
|
|
5831
5825
|
// Accents on an otherwise muted line: the `sub:` chip. Each reset reopens
|
|
5832
5826
|
// mute, the same way the pre-strip footer did. `styleLine` sanitises first,
|
|
5833
5827
|
// so the accent is spliced in after.
|
|
5834
5828
|
let statusLine = this.styleLine('system', statusText);
|
|
5835
|
-
if (this.color && this.notice === undefined) {
|
|
5829
|
+
if (this.color && this.notice === undefined && echoText === undefined) {
|
|
5836
5830
|
// The chip is only accented when it survived the fit: a `sub:` route the
|
|
5837
5831
|
// row could not afford is not on the line, and `paintFooterSubagentChip`
|
|
5838
5832
|
// returns the line unchanged when it cannot find its text.
|
|
@@ -8423,6 +8417,13 @@ export class SshTui {
|
|
|
8423
8417
|
const options = {
|
|
8424
8418
|
provider,
|
|
8425
8419
|
model,
|
|
8420
|
+
// The reviewer is a direct LLM call outside the Agent loop; the loop normally
|
|
8421
|
+
// supplies this identity. OpenCode requires a per-conversation session
|
|
8422
|
+
// signal: pi-ai's built-in provider turns this into `x-opencode-session`,
|
|
8423
|
+
// while the configured openai-responses route sends `session_id`. Other
|
|
8424
|
+
// providers may use it for their own affinity/cache routing. It must be the
|
|
8425
|
+
// Session that actually asked for approval, never a shared or random id.
|
|
8426
|
+
sessionId: request.agent.session.id,
|
|
8426
8427
|
messages: [createUserMessage({
|
|
8427
8428
|
content: [{ type: 'text', text: buildReviewUserMessage({
|
|
8428
8429
|
userText,
|
|
@@ -8802,6 +8803,21 @@ export class SshTui {
|
|
|
8802
8803
|
const at = this.fieldCursor();
|
|
8803
8804
|
this.setField(`${current.slice(0, at)}${text}${current.slice(at)}`, at + text.length);
|
|
8804
8805
|
}
|
|
8806
|
+
/**
|
|
8807
|
+
* Move the caret of whichever field owns the keyboard to one end (Home/End).
|
|
8808
|
+
*
|
|
8809
|
+
* One implementation for the two owners that carry these keys (W-3b.1): the composer
|
|
8810
|
+
* edits the draft, a Surface with a borrowed field edits the answer, and a Surface
|
|
8811
|
+
* without one has nothing to move — `textIsEditable` is what says which of those the
|
|
8812
|
+
* keyboard is on. Ctrl+A/Ctrl+E reach the same two positions through `composerUnit`,
|
|
8813
|
+
* which asks the same question.
|
|
8814
|
+
*/
|
|
8815
|
+
moveFieldCursor(target) {
|
|
8816
|
+
if (!this.textIsEditable())
|
|
8817
|
+
return;
|
|
8818
|
+
this.setField(this.fieldText(), target === 'start' ? 0 : this.fieldText().length);
|
|
8819
|
+
this.markDirty();
|
|
8820
|
+
}
|
|
8805
8821
|
/** One card's key: a call's batch can repeat a question id across calls. */
|
|
8806
8822
|
questionCardKey(callId, questionId) {
|
|
8807
8823
|
return `${callId ?? `live:${this.liveQuestionSeq}`}\u0000${questionId}`;
|
|
@@ -10939,18 +10955,91 @@ export class SshTui {
|
|
|
10939
10955
|
this.markDirty();
|
|
10940
10956
|
}
|
|
10941
10957
|
/**
|
|
10942
|
-
*
|
|
10958
|
+
* The two tools a 0.1.7 terminal profile mounts itself, read from the running
|
|
10959
|
+
* composition. The profile patch is only the instruction; this is the answer
|
|
10960
|
+
* to whether the instruction actually produced a tool.
|
|
10961
|
+
*/
|
|
10962
|
+
agentPlaneTools() {
|
|
10963
|
+
const tools = this.ctx.get('tools');
|
|
10964
|
+
return {
|
|
10965
|
+
askUser: tools?.get('ask_user_question') !== undefined,
|
|
10966
|
+
present: tools?.get('present') !== undefined,
|
|
10967
|
+
};
|
|
10968
|
+
}
|
|
10969
|
+
/** Whether this process's agent plane has both tools. */
|
|
10970
|
+
agentPlaneReady() {
|
|
10971
|
+
const tools = this.agentPlaneTools();
|
|
10972
|
+
return tools.askUser && tools.present;
|
|
10973
|
+
}
|
|
10974
|
+
/**
|
|
10975
|
+
* Repair an already-configured profile at boot and say a restart is needed.
|
|
10943
10976
|
*
|
|
10944
|
-
*
|
|
10945
|
-
*
|
|
10946
|
-
*
|
|
10977
|
+
* A first run deliberately does not call this: its wizard save is the point
|
|
10978
|
+
* where settings and credentials exist, and `onboardDone` repairs immediately
|
|
10979
|
+
* after that write. This method serves the old/partial profile upgrade path,
|
|
10980
|
+
* where there is no wizard to wait for.
|
|
10981
|
+
*/
|
|
10982
|
+
async repairAgentPlaneAtBoot() {
|
|
10983
|
+
const profile = profileFromArgv();
|
|
10984
|
+
try {
|
|
10985
|
+
const repaired = await this.ensureProfileAgentPlane(profile);
|
|
10986
|
+
if (repaired === 'written') {
|
|
10987
|
+
this.pushRow(represent('boot', {
|
|
10988
|
+
kind: 'system',
|
|
10989
|
+
text: t('onboard.agentPlaneAdded', {
|
|
10990
|
+
path: rosterPatchPath(resolveDshHome(), profile),
|
|
10991
|
+
profile,
|
|
10992
|
+
}),
|
|
10993
|
+
}));
|
|
10994
|
+
this.refreshRosterHealth();
|
|
10995
|
+
this.markDirty();
|
|
10996
|
+
}
|
|
10997
|
+
}
|
|
10998
|
+
catch (error) {
|
|
10999
|
+
// A broken or unwritable patch must not be swallowed: the workspace can
|
|
11000
|
+
// still boot, but the missing tools stay visible in the footer and
|
|
11001
|
+
// `/doctor` explains how to repair the file.
|
|
11002
|
+
this.pushRow(represent('boot', {
|
|
11003
|
+
kind: 'error',
|
|
11004
|
+
text: t('onboard.agentPlaneFailed', {
|
|
11005
|
+
error: errorChain(error),
|
|
11006
|
+
path: rosterPatchPath(resolveDshHome(), profile),
|
|
11007
|
+
}),
|
|
11008
|
+
}));
|
|
11009
|
+
this.markDirty();
|
|
11010
|
+
}
|
|
11011
|
+
}
|
|
11012
|
+
/**
|
|
11013
|
+
* Add only the profile rows whose tools are missing from this composition.
|
|
11014
|
+
*
|
|
11015
|
+
* A row that is already present in the patch is never written again, even if
|
|
11016
|
+
* its service failed to activate: the planner sees overrides and disables by
|
|
11017
|
+
* id or module name, and `ensureRosterRows` creates a backup first. This is
|
|
11018
|
+
* the first-run/upgrade repair, not an install-hook claim: it changes what
|
|
11019
|
+
* the next boot composes, not what this process has already composed.
|
|
10947
11020
|
*/
|
|
11021
|
+
async ensureProfileAgentPlane(profile = profileFromArgv()) {
|
|
11022
|
+
const generation = this.settingsGeneration;
|
|
11023
|
+
const tools = this.agentPlaneTools();
|
|
11024
|
+
const missing = generation === 'forms'
|
|
11025
|
+
? rosterRows(generation).filter(row => row.id === 'tool-ask-user' ? !tools.askUser : !tools.present)
|
|
11026
|
+
: rosterRows(generation).filter(row => row.id === 'agent-presets'
|
|
11027
|
+
? this.ctx.get('agentPresets') === undefined
|
|
11028
|
+
: row.id === 'code-runtime'
|
|
11029
|
+
? !this.hasHostService('codeRuntime')
|
|
11030
|
+
: true);
|
|
11031
|
+
if (missing.length === 0)
|
|
11032
|
+
return 'present';
|
|
11033
|
+
return ensureRosterRows(resolveDshHome(), profile, missing, generation);
|
|
11034
|
+
}
|
|
10948
11035
|
/** Re-read the roster fact this process was composed with (cheap, no IO). */
|
|
10949
11036
|
refreshRosterHealth() {
|
|
10950
11037
|
// 0.1.7 has no roster to be missing: what a terminal profile owns there is
|
|
10951
|
-
// the agent-plane
|
|
11038
|
+
// the agent-plane tools, so the chip follows the tools the composition
|
|
11039
|
+
// actually registered. Reading the patch alone would call a broken load
|
|
11040
|
+
// healthy as soon as the row text was written.
|
|
10952
11041
|
this.rosterMissing = this.settingsGeneration === 'forms'
|
|
10953
|
-
? !
|
|
11042
|
+
? !this.agentPlaneReady()
|
|
10954
11043
|
: this.ctx.get('agentPresets') === undefined;
|
|
10955
11044
|
}
|
|
10956
11045
|
/** Which settings protocol this host speaks; decides what "missing" means. */
|
|
@@ -10971,6 +11060,14 @@ export class SshTui {
|
|
|
10971
11060
|
? [first, t('mode.formsHost'), t('mode.formsHint', { profile, patch })].join('\n')
|
|
10972
11061
|
: [first, t('mode.missingServiceHint', { profile, patch })].join('\n');
|
|
10973
11062
|
}
|
|
11063
|
+
/**
|
|
11064
|
+
* `/mode fix`: write the missing profile rows into the user patch layer.
|
|
11065
|
+
*
|
|
11066
|
+
* The write only takes effect on the next launch — the loader composes the
|
|
11067
|
+
* patch tree once at boot — so the report says so instead of pretending the
|
|
11068
|
+
* running session gained a roster. The helper consults the actual tools, so
|
|
11069
|
+
* a row a bundle already supplies is not mounted a second time.
|
|
11070
|
+
*/
|
|
10974
11071
|
async repairRoster() {
|
|
10975
11072
|
const profile = profileFromArgv();
|
|
10976
11073
|
const patch = rosterPatchPath(resolveDshHome(), profile);
|
|
@@ -10979,8 +11076,7 @@ export class SshTui {
|
|
|
10979
11076
|
// process-wide and owns the agent-plane rows instead of the roster. Both
|
|
10980
11077
|
// the list and the header come from the same generation `/doctor` uses,
|
|
10981
11078
|
// or this writes rows the running host cannot resolve.
|
|
10982
|
-
const
|
|
10983
|
-
const result = await ensureRosterRows(resolveDshHome(), profile, rosterRows(generation), generation);
|
|
11079
|
+
const result = await this.ensureProfileAgentPlane(profile);
|
|
10984
11080
|
this.refreshRosterHealth();
|
|
10985
11081
|
this.pushRow(represent('repair-result', {
|
|
10986
11082
|
kind: 'system',
|
|
@@ -11328,10 +11424,7 @@ export class SshTui {
|
|
|
11328
11424
|
}
|
|
11329
11425
|
// ── keyboard ────────────────────────────────────────────────────────────
|
|
11330
11426
|
handleData = (chunk) => {
|
|
11331
|
-
|
|
11332
|
-
if (decoded === '')
|
|
11333
|
-
return;
|
|
11334
|
-
this.inputGuard.push(decoded);
|
|
11427
|
+
this.reader.push(chunk);
|
|
11335
11428
|
};
|
|
11336
11429
|
/**
|
|
11337
11430
|
* One key, handled by the Screen that is up.
|
|
@@ -11357,30 +11450,13 @@ export class SshTui {
|
|
|
11357
11450
|
}
|
|
11358
11451
|
// A burst of line breaks in one read is a paste from a console that cannot say so
|
|
11359
11452
|
// (`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)) {
|
|
11453
|
+
// pasted key or model list survives a terminal without bracketed paste. The Reader
|
|
11454
|
+
// keeps such a read whole for exactly this check — splitting it is what would turn
|
|
11455
|
+
// one paste into a step's worth of Enters.
|
|
11456
|
+
if (InputReader.looksLikePasteBurst(combined)) {
|
|
11364
11457
|
this.pasteIntoScreen(combined);
|
|
11365
11458
|
return;
|
|
11366
11459
|
}
|
|
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
11460
|
// The setup Screen owns its own field, its own navigation and its own keys: it
|
|
11385
11461
|
// consumes everything while it is up, and nothing reaches the workspace's
|
|
11386
11462
|
// composer, history or transcript (B2.6 §4).
|
|
@@ -11524,31 +11600,86 @@ export class SshTui {
|
|
|
11524
11600
|
screen.notice = caveat ?? notice;
|
|
11525
11601
|
this.markDirty();
|
|
11526
11602
|
}
|
|
11603
|
+
/**
|
|
11604
|
+
* One read, as the Reader framed it: paste traffic is dispatched per frame,
|
|
11605
|
+
* everything else is routed (W-2 lifts the routing itself into a Router).
|
|
11606
|
+
*/
|
|
11527
11607
|
handleInputText(text) {
|
|
11528
|
-
const combined = this.
|
|
11529
|
-
|
|
11530
|
-
|
|
11531
|
-
|
|
11532
|
-
|
|
11533
|
-
|
|
11534
|
-
//
|
|
11535
|
-
//
|
|
11536
|
-
//
|
|
11537
|
-
|
|
11538
|
-
|
|
11539
|
-
|
|
11540
|
-
|
|
11541
|
-
//
|
|
11542
|
-
//
|
|
11543
|
-
//
|
|
11544
|
-
|
|
11608
|
+
const combined = this.reader.assemble(text);
|
|
11609
|
+
// A read is not a key. The Reader decides where one unit ends — complete
|
|
11610
|
+
// sequences, Alt chords, control bytes, CR/LF, and runs of typing — and each unit
|
|
11611
|
+
// is framed once and routed once, whichever owner it belongs to (AD-20 §3, W-3a).
|
|
11612
|
+
// Bracketed paste is framed there too: while inside a paste, CR/LF are literal
|
|
11613
|
+
// characters rather than submit, so a copied multi-line error message arrives as
|
|
11614
|
+
// one message.
|
|
11615
|
+
//
|
|
11616
|
+
// A Screen owns the read it was sent, including the units after it closes: 0.8.3
|
|
11617
|
+
// dropped the rest of such a read (`handleScreenInput` returned as soon as its
|
|
11618
|
+
// Screen was gone) and that rule is a safety one, not an artefact — the keys after
|
|
11619
|
+
// a dismissal were addressed to the Screen, and letting them land on whatever is
|
|
11620
|
+
// behind it is how a paste ended up in the composer. A Surface is the other way
|
|
11621
|
+
// round, and deliberately: its answer is a character like any other, and the rest
|
|
11622
|
+
// of the read is routed on (`yes` typed at a confirm is refused by the Surface and
|
|
11623
|
+
// leaves the draft alone — pinned by `yes-typed-at-dialog`).
|
|
11624
|
+
const dismissals = this.screenDismissals;
|
|
11625
|
+
for (const frame of this.reader.read(combined)) {
|
|
11626
|
+
if (this.screenDismissals !== dismissals)
|
|
11627
|
+
return;
|
|
11628
|
+
this.consumeFrame(frame);
|
|
11629
|
+
}
|
|
11630
|
+
}
|
|
11631
|
+
/** One framed unit, at the owner that takes it. */
|
|
11632
|
+
consumeFrame(frame) {
|
|
11633
|
+
switch (frame.kind) {
|
|
11634
|
+
case 'paste':
|
|
11635
|
+
this.dispatchPaste(frame.text);
|
|
11636
|
+
return;
|
|
11637
|
+
// A sequence tail, an Alt remainder or a released hold: typed as it is, with
|
|
11638
|
+
// no keymap lookup — which is what the inline version did at those three sites.
|
|
11639
|
+
case 'literal':
|
|
11640
|
+
this.handlePlainText(frame.text);
|
|
11641
|
+
return;
|
|
11642
|
+
case 'escape':
|
|
11643
|
+
this.handleChar('\x1b');
|
|
11644
|
+
return;
|
|
11645
|
+
// A device reply is the prober's input, never an owner's. W-1 keeps today's
|
|
11646
|
+
// behaviour (dropped here); W-2 wires it to the prober that asked.
|
|
11647
|
+
case 'reply': return;
|
|
11648
|
+
// `key` and `burst` reach the same routing today. The distinction is what W-2
|
|
11649
|
+
// routes on (one key versus several in one read), so the Reader decides it.
|
|
11650
|
+
case 'key':
|
|
11651
|
+
this.route(frame.sequence);
|
|
11652
|
+
return;
|
|
11653
|
+
case 'burst':
|
|
11654
|
+
this.route(frame.text);
|
|
11655
|
+
return;
|
|
11656
|
+
}
|
|
11657
|
+
}
|
|
11658
|
+
/**
|
|
11659
|
+
* One read from the Reader, as the ladder has always taken it.
|
|
11660
|
+
*
|
|
11661
|
+
* Two kinds of table live here and they are not the same thing:
|
|
11662
|
+
*
|
|
11663
|
+
* - **binding** — which byte means which action (the keymap's rebindable actions,
|
|
11664
|
+
* the Alt chords, the SS3 tail). Those stay; AD-20 F-5 wants one table per key.
|
|
11665
|
+
* - **ownership** — who takes the unit. That question is the Router's now, and it
|
|
11666
|
+
* is asked exactly once per unit, before any binding table is consulted: a
|
|
11667
|
+
* Screen owns the read outright, so the tables below are for the unlayered case,
|
|
11668
|
+
* which is the order the ladder always had.
|
|
11669
|
+
*
|
|
11670
|
+
* The parsing branches that are neither (a read that could still grow — the Reader
|
|
11671
|
+
* holds it; an escape sequence nobody claimed — dropped) stay here as well.
|
|
11672
|
+
*/
|
|
11673
|
+
route(combined) {
|
|
11674
|
+
// A key the user moved, and a key their move displaced.
|
|
11545
11675
|
const moved = combined === '' ? undefined : this.keymap.sequences.get(combined);
|
|
11546
|
-
|
|
11547
|
-
|
|
11548
|
-
|
|
11549
|
-
//
|
|
11550
|
-
|
|
11551
|
-
|
|
11676
|
+
const kind = this.readKind(combined);
|
|
11677
|
+
this.observe(kind, combined);
|
|
11678
|
+
const owner = this.router.route(kind, combined, this.ownershipView);
|
|
11679
|
+
// A Screen owns the read outright (AD-20 §4.3 priority 3): nothing below this
|
|
11680
|
+
// line runs for it — no keymap action, no Alt chord, no bare ESC for the composer.
|
|
11681
|
+
if (owner === 'screen') {
|
|
11682
|
+
this.ownerScreen(combined, moved);
|
|
11552
11683
|
return;
|
|
11553
11684
|
}
|
|
11554
11685
|
if (this.dialog === undefined && combined !== '') {
|
|
@@ -11559,124 +11690,14 @@ export class SshTui {
|
|
|
11559
11690
|
if (this.dialog === undefined && this.keymap.suppressed.has(combined))
|
|
11560
11691
|
return;
|
|
11561
11692
|
}
|
|
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);
|
|
11693
|
+
if (this.reader.isDeviceReply(combined)) {
|
|
11694
|
+
this.consumeFrame({ kind: 'reply', sequence: combined });
|
|
11651
11695
|
return;
|
|
11652
11696
|
}
|
|
11653
|
-
|
|
11654
|
-
|
|
11655
|
-
|
|
11656
|
-
|
|
11657
|
-
if (combined === '\x1b[6~') {
|
|
11658
|
-
this.scrollActiveSurface(-Math.max(3, Math.floor(this.screenRows() / 2)));
|
|
11659
|
-
return;
|
|
11660
|
-
}
|
|
11661
|
-
if (parseCursorPositionReply(combined) !== undefined)
|
|
11662
|
-
return;
|
|
11663
|
-
if (combined === '\x1b[H' || combined === '\x1b[1~') {
|
|
11664
|
-
this.cursor = 0;
|
|
11665
|
-
this.markDirty();
|
|
11666
|
-
return;
|
|
11667
|
-
}
|
|
11668
|
-
if (combined === '\x1b[F' || combined === '\x1b[4~') {
|
|
11669
|
-
this.cursor = this.input.length;
|
|
11670
|
-
this.markDirty();
|
|
11671
|
-
return;
|
|
11672
|
-
}
|
|
11673
|
-
if (combined === '\x1b[3~') {
|
|
11674
|
-
this.deleteAtCursor();
|
|
11675
|
-
return;
|
|
11676
|
-
}
|
|
11677
|
-
// kitty / CSI-u Ctrl+Shift+C (codepoint 99, mods 6 = Ctrl+Shift)
|
|
11678
|
-
if (combined === '\x1b[99;6u') {
|
|
11679
|
-
this.copyKey();
|
|
11697
|
+
// The sequence shapes the ladder matched before its hold window: they go to their
|
|
11698
|
+
// owner as one unit, and only what is left over can still grow (the Reader holds it).
|
|
11699
|
+
if (this.isSequenceShape(combined)) {
|
|
11700
|
+
this.dispatchWith(owner, combined, moved);
|
|
11680
11701
|
return;
|
|
11681
11702
|
}
|
|
11682
11703
|
const ss3 = /^\x1bO[A-Z]/u.exec(combined);
|
|
@@ -11686,30 +11707,10 @@ export class SshTui {
|
|
|
11686
11707
|
this.handlePlainText(rest);
|
|
11687
11708
|
return;
|
|
11688
11709
|
}
|
|
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);
|
|
11710
|
+
// A read that could still grow into a sequence is the Reader's to hold, and the
|
|
11711
|
+
// window is the same 60 ms it always was (AD-21: moved, not changed).
|
|
11712
|
+
if (this.reader.hold(combined))
|
|
11711
11713
|
return;
|
|
11712
|
-
}
|
|
11713
11714
|
if (combined.startsWith('\x1b[') || combined.startsWith('\x1bO')) {
|
|
11714
11715
|
// Unknown escape sequence (including SS3 function keys) — consume
|
|
11715
11716
|
// without side effects.
|
|
@@ -11741,167 +11742,321 @@ export class SshTui {
|
|
|
11741
11742
|
this.stepSearch(-1);
|
|
11742
11743
|
return;
|
|
11743
11744
|
}
|
|
11745
|
+
// The `/find` seed is composer *text*, so it may only be typed into a field
|
|
11746
|
+
// the composer owns (W-3b.3a). This branch runs before any owner is asked
|
|
11747
|
+
// (the Alt chords are workspace-global, AD-20 §1.3 step 13), so with a
|
|
11748
|
+
// Surface up it used to write the hidden draft — the one thing F-2 forbids —
|
|
11749
|
+
// and the reader saw their next message replaced by `/find `. A chord is not
|
|
11750
|
+
// an owner, so the key is consumed here instead of reaching behind the
|
|
11751
|
+
// Surface. `textIsEditable()` alone is not this question: it answers "a field
|
|
11752
|
+
// is editable", and a borrowed answer is one — the seed is the *composer's*
|
|
11753
|
+
// field, which is exactly `borrowedText === undefined`.
|
|
11744
11754
|
if (alt === 'f' || alt === 'F' || alt === '/') {
|
|
11745
|
-
this.
|
|
11746
|
-
|
|
11747
|
-
|
|
11755
|
+
if (this.textIsEditable() && this.borrowedText === undefined) {
|
|
11756
|
+
this.input = '/find ';
|
|
11757
|
+
this.cursor = this.input.length;
|
|
11758
|
+
this.markDirty();
|
|
11759
|
+
}
|
|
11748
11760
|
return;
|
|
11749
11761
|
}
|
|
11750
11762
|
// Other Alt+<key>: ignore ESC so it does not cancel, type the remainder.
|
|
11751
11763
|
this.handlePlainText(alt);
|
|
11752
11764
|
return;
|
|
11753
11765
|
}
|
|
11754
|
-
this.
|
|
11766
|
+
this.dispatchWith(owner, combined, moved);
|
|
11755
11767
|
}
|
|
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
|
-
|
|
11768
|
+
/**
|
|
11769
|
+
* The Router's read-only view, rewritten per decision (no allocation).
|
|
11770
|
+
*
|
|
11771
|
+
* The card question is the only expensive one — `collapsibleRows()` walks the
|
|
11772
|
+
* visible transcript — so it is answered only for the arrow that asks it, and only
|
|
11773
|
+
* when nothing is layered over the workspace.
|
|
11774
|
+
*/
|
|
11775
|
+
observe(kind, unit) {
|
|
11776
|
+
const view = this.ownershipView;
|
|
11777
|
+
const screen = this.screen;
|
|
11778
|
+
const surface = this.dialog;
|
|
11779
|
+
view.screenKind = screen?.kind;
|
|
11780
|
+
view.surfaceKind = surface?.kind;
|
|
11781
|
+
view.surfaceHasField = this.borrowedText !== undefined;
|
|
11782
|
+
view.composerEmpty = this.fieldText() === '';
|
|
11783
|
+
view.workspaceCards = screen === undefined && surface === undefined
|
|
11784
|
+
&& kind === 'sequence'
|
|
11785
|
+
&& (unit === '\x1b[A' || unit === '\x1b[B')
|
|
11786
|
+
&& view.composerEmpty
|
|
11787
|
+
&& this.collapsibleRows().length > 0;
|
|
11788
|
+
}
|
|
11789
|
+
/**
|
|
11790
|
+
* Whether a read is one of the sequence shapes the ladder claims as a whole.
|
|
11791
|
+
*
|
|
11792
|
+
* These are matched *before* the Reader's hold window — the order the ladder always
|
|
11793
|
+
* had (`ESC [ 5 ~` is a page key, not a prefix that might still grow).
|
|
11794
|
+
*/
|
|
11795
|
+
isSequenceShape(unit) {
|
|
11796
|
+
return /^\x1b\[[A-D]$/u.test(unit)
|
|
11797
|
+
|| SGR_MOUSE_RE.test(unit)
|
|
11798
|
+
|| unit === '\x1b[5~' || unit === '\x1b[6~'
|
|
11799
|
+
|| unit === '\x1b[H' || unit === '\x1b[1~' || unit === '\x1b[F' || unit === '\x1b[4~'
|
|
11800
|
+
|| unit === '\x1b[3~' || unit === '\x1b[99;6u';
|
|
11801
|
+
}
|
|
11802
|
+
/** The unit class of one read, by shape (the Reader's classification, allocation-free). */
|
|
11803
|
+
readKind(combined) {
|
|
11804
|
+
if (combined === '\x1b')
|
|
11805
|
+
return 'escape';
|
|
11806
|
+
if (combined.length === 1)
|
|
11807
|
+
return combined < ' ' || combined === '\x7f' ? 'control' : 'text';
|
|
11808
|
+
return combined.startsWith('\x1b') ? 'sequence' : 'text';
|
|
11809
|
+
}
|
|
11810
|
+
/** The unit class of one character — what `handleChar` routes. */
|
|
11811
|
+
charKind(char) {
|
|
11812
|
+
if (char === '\x1b')
|
|
11813
|
+
return 'escape';
|
|
11814
|
+
return char < ' ' || char === '\x7f' ? 'control' : 'text';
|
|
11815
|
+
}
|
|
11816
|
+
/** One unit, one owner: the Router decides, the owner adapter runs it. */
|
|
11817
|
+
dispatch(kind, unit, moved) {
|
|
11818
|
+
this.observe(kind, unit);
|
|
11819
|
+
this.dispatchWith(this.router.route(kind, unit, this.ownershipView), unit, moved);
|
|
11820
|
+
}
|
|
11821
|
+
/** Run a unit at the owner the Router already named. */
|
|
11822
|
+
dispatchWith(owner, unit, moved) {
|
|
11823
|
+
switch (owner) {
|
|
11824
|
+
case 'screen':
|
|
11825
|
+
this.ownerScreen(unit, moved);
|
|
11826
|
+
return;
|
|
11827
|
+
case 'surface':
|
|
11828
|
+
this.ownerSurface(unit, moved);
|
|
11829
|
+
return;
|
|
11830
|
+
case 'composer':
|
|
11831
|
+
this.ownerComposer(unit, moved);
|
|
11832
|
+
return;
|
|
11833
|
+
case 'workspace':
|
|
11834
|
+
this.ownerWorkspace(unit, moved);
|
|
11835
|
+
return;
|
|
11787
11836
|
}
|
|
11788
11837
|
}
|
|
11789
11838
|
/**
|
|
11790
|
-
*
|
|
11791
|
-
* newlines.
|
|
11839
|
+
* A paste payload: the Router decides, the paste path executes.
|
|
11792
11840
|
*
|
|
11793
|
-
* A paste is text
|
|
11794
|
-
*
|
|
11795
|
-
*
|
|
11796
|
-
*
|
|
11797
|
-
|
|
11798
|
-
|
|
11841
|
+
* A paste is text, so the field that owns the keyboard takes it — and a Screen that
|
|
11842
|
+
* owns the interaction terminates it (its own field or nothing), never a field
|
|
11843
|
+
* behind it (AD-20 §4.3 priority 1). `handlePasteText` still re-asks that per owner;
|
|
11844
|
+
* that is the compat path W-3 folds into the Router (marked there).
|
|
11845
|
+
*/
|
|
11846
|
+
dispatchPaste(text) {
|
|
11847
|
+
this.observe('paste', text);
|
|
11848
|
+
if (this.router.route('paste', text, this.ownershipView) === 'workspace')
|
|
11849
|
+
return;
|
|
11850
|
+
this.handlePasteText(text);
|
|
11851
|
+
}
|
|
11852
|
+
/**
|
|
11853
|
+
* The Screen adapter: a Screen owns the keyboard outright (AD-20 §4.3 priority 3).
|
|
11799
11854
|
*
|
|
11800
|
-
*
|
|
11801
|
-
*
|
|
11802
|
-
*
|
|
11803
|
-
* and
|
|
11855
|
+
* One unit goes in — the Reader split the read, so a Screen sees the same keys the
|
|
11856
|
+
* composer would (W-3a) and `handleScreenInput` is where the paste-burst rule stays.
|
|
11857
|
+
* Its own `screen === undefined` guard stays too: a release raced by a teardown
|
|
11858
|
+
* reaches it, and refusing there is the adapter's own business rather than a second
|
|
11859
|
+
* ownership decision.
|
|
11804
11860
|
*/
|
|
11805
|
-
|
|
11806
|
-
|
|
11807
|
-
|
|
11861
|
+
ownerScreen(unit, moved) {
|
|
11862
|
+
this.handleScreenInput(unit, moved ?? this.keymap.sequences.get(unit));
|
|
11863
|
+
}
|
|
11864
|
+
/**
|
|
11865
|
+
* The Surface adapter: a dialog owns the keyboard while it is up.
|
|
11866
|
+
*
|
|
11867
|
+
* Its arrows move a question's cursor (or nothing — an arrow a Surface does not use
|
|
11868
|
+
* is not the transcript's), the page keys are not its business, and its mouse reports
|
|
11869
|
+
* take the same path they always did so the refusal happens where it always did, down
|
|
11870
|
+
* to the remembered press. Its own editing keys are `Home`/`End`/`Delete` (W-3b.1):
|
|
11871
|
+
* they act on the field the Surface borrowed — the one row the reader can see — and a
|
|
11872
|
+
* Surface that borrowed none has no field for them, so it consumes them. The kitty
|
|
11873
|
+
* copy key falls out with the other sequences below: a Surface has no copy contract,
|
|
11874
|
+
* and this round did not invent one. Everything else goes to the shared
|
|
11875
|
+
* composer/Surface body, whose `dialog` branches are the Surface's own behaviour.
|
|
11876
|
+
*/
|
|
11877
|
+
ownerSurface(unit, moved) {
|
|
11878
|
+
if (unit === '\x1b[A') {
|
|
11879
|
+
this.moveQuestionCursor(-1);
|
|
11808
11880
|
return;
|
|
11809
|
-
|
|
11810
|
-
|
|
11881
|
+
}
|
|
11882
|
+
if (unit === '\x1b[B') {
|
|
11883
|
+
this.moveQuestionCursor(1);
|
|
11811
11884
|
return;
|
|
11812
11885
|
}
|
|
11813
|
-
|
|
11886
|
+
const sgr = SGR_MOUSE_RE.exec(unit);
|
|
11887
|
+
if (sgr !== null) {
|
|
11888
|
+
const button = Number(sgr[1]);
|
|
11889
|
+
if (button === 64 || button === 65)
|
|
11890
|
+
return;
|
|
11891
|
+
this.mouseGesture(sgr);
|
|
11814
11892
|
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
11893
|
}
|
|
11825
|
-
|
|
11894
|
+
if (unit === '\x1b[H' || unit === '\x1b[1~') {
|
|
11895
|
+
this.moveFieldCursor('start');
|
|
11896
|
+
return;
|
|
11897
|
+
}
|
|
11898
|
+
if (unit === '\x1b[F' || unit === '\x1b[4~') {
|
|
11899
|
+
this.moveFieldCursor('end');
|
|
11900
|
+
return;
|
|
11901
|
+
}
|
|
11902
|
+
if (unit === '\x1b[3~') {
|
|
11903
|
+
// Only a borrowed field can be edited: the draft behind the Surface is not the
|
|
11904
|
+
// field on screen, and an answer the reader cannot see is not an answer.
|
|
11905
|
+
if (this.textIsEditable())
|
|
11906
|
+
this.deleteAtCursor();
|
|
11907
|
+
return;
|
|
11908
|
+
}
|
|
11909
|
+
if (unit.startsWith('\x1b[') || unit.startsWith('\x1bO'))
|
|
11910
|
+
return;
|
|
11911
|
+
this.composerUnit(unit, moved);
|
|
11826
11912
|
}
|
|
11827
11913
|
/**
|
|
11828
|
-
*
|
|
11914
|
+
* The Composer adapter.
|
|
11829
11915
|
*
|
|
11830
|
-
*
|
|
11831
|
-
*
|
|
11832
|
-
*
|
|
11833
|
-
*
|
|
11834
|
-
*
|
|
11835
|
-
*
|
|
11916
|
+
* Text — one character or many — is typed: the burst rule first, then one character
|
|
11917
|
+
* at a time through the Router, which is what lets ownership change inside a single
|
|
11918
|
+
* read (a Surface answering `y` with the composer taking the `es` behind it). The
|
|
11919
|
+
* sequence shapes the composer owns are the arrows (the suggestion list, else
|
|
11920
|
+
* history) and the caret keys — which it now only ever gets while it owns the
|
|
11921
|
+
* keyboard: with a Surface up, the Router hands those keys to the Surface (W-3b.1).
|
|
11836
11922
|
*/
|
|
11837
|
-
|
|
11838
|
-
|
|
11839
|
-
|
|
11923
|
+
ownerComposer(unit, moved) {
|
|
11924
|
+
if (unit === '\x1b[A') {
|
|
11925
|
+
this.arrowHistory(-1);
|
|
11840
11926
|
return;
|
|
11841
|
-
|
|
11927
|
+
}
|
|
11928
|
+
if (unit === '\x1b[B') {
|
|
11929
|
+
this.arrowHistory(1);
|
|
11842
11930
|
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 === '')
|
|
11931
|
+
}
|
|
11932
|
+
if (unit === '\x1b[C') {
|
|
11933
|
+
if (this.textIsEditable())
|
|
11934
|
+
this.moveCursor(1);
|
|
11850
11935
|
return;
|
|
11851
|
-
|
|
11852
|
-
|
|
11936
|
+
}
|
|
11937
|
+
if (unit === '\x1b[D') {
|
|
11938
|
+
if (this.textIsEditable())
|
|
11939
|
+
this.moveCursor(-1);
|
|
11940
|
+
return;
|
|
11941
|
+
}
|
|
11942
|
+
// The caret keys go through the field the keyboard is editing, like Ctrl+A/Ctrl+E
|
|
11943
|
+
// below: with no Surface up that field *is* the draft, so this is the write it
|
|
11944
|
+
// always was — and it can no longer be the wrong field if a later round routes a
|
|
11945
|
+
// unit here while a field is borrowed.
|
|
11946
|
+
if (unit === '\x1b[H' || unit === '\x1b[1~') {
|
|
11947
|
+
this.moveFieldCursor('start');
|
|
11948
|
+
return;
|
|
11949
|
+
}
|
|
11950
|
+
if (unit === '\x1b[F' || unit === '\x1b[4~') {
|
|
11951
|
+
this.moveFieldCursor('end');
|
|
11952
|
+
return;
|
|
11953
|
+
}
|
|
11954
|
+
if (unit === '\x1b[3~') {
|
|
11955
|
+
this.deleteAtCursor();
|
|
11956
|
+
return;
|
|
11957
|
+
}
|
|
11958
|
+
if (unit === '\x1b[99;6u') {
|
|
11959
|
+
this.copyKey();
|
|
11960
|
+
return;
|
|
11961
|
+
}
|
|
11962
|
+
this.composerUnit(unit, moved);
|
|
11853
11963
|
}
|
|
11854
11964
|
/**
|
|
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.
|
|
11965
|
+
* The Workspace adapter: the transcript owns the mouse, the page keys and the focus
|
|
11966
|
+
* ring — and the ring only when the Router said so (empty field, a card to land on).
|
|
11863
11967
|
*/
|
|
11864
|
-
|
|
11865
|
-
|
|
11866
|
-
|
|
11867
|
-
|
|
11968
|
+
ownerWorkspace(unit, moved) {
|
|
11969
|
+
void moved;
|
|
11970
|
+
const sgr = SGR_MOUSE_RE.exec(unit);
|
|
11971
|
+
if (sgr !== null) {
|
|
11972
|
+
const button = Number(sgr[1]);
|
|
11973
|
+
if (button === 64) {
|
|
11974
|
+
this.scrollWorkspace(3);
|
|
11975
|
+
return;
|
|
11976
|
+
}
|
|
11977
|
+
if (button === 65) {
|
|
11978
|
+
this.scrollWorkspace(-3);
|
|
11979
|
+
return;
|
|
11980
|
+
}
|
|
11981
|
+
this.mouseGesture(sgr);
|
|
11982
|
+
return;
|
|
11983
|
+
}
|
|
11984
|
+
const page = Math.max(3, Math.floor(this.screenRows() / 2));
|
|
11985
|
+
if (unit === '\x1b[5~') {
|
|
11986
|
+
this.scrollWorkspace(page);
|
|
11987
|
+
return;
|
|
11988
|
+
}
|
|
11989
|
+
if (unit === '\x1b[6~') {
|
|
11990
|
+
this.scrollWorkspace(-page);
|
|
11991
|
+
return;
|
|
11992
|
+
}
|
|
11993
|
+
if (unit === '\x1b[A') {
|
|
11994
|
+
this.moveFocus(-1);
|
|
11995
|
+
return;
|
|
11996
|
+
}
|
|
11997
|
+
if (unit === '\x1b[B') {
|
|
11998
|
+
this.moveFocus(1);
|
|
11999
|
+
return;
|
|
12000
|
+
}
|
|
11868
12001
|
}
|
|
11869
|
-
|
|
11870
|
-
|
|
11871
|
-
|
|
11872
|
-
|
|
11873
|
-
|
|
12002
|
+
/** An arrow the composer keeps: the suggestion list while it is up, else history. */
|
|
12003
|
+
arrowHistory(direction) {
|
|
12004
|
+
if (this.suggestionsVisible()) {
|
|
12005
|
+
this.suggestionIndex = direction < 0
|
|
12006
|
+
? Math.max(0, this.suggestionIndex - 1)
|
|
12007
|
+
: Math.min(this.commandSuggestions.length - 1, this.suggestionIndex + 1);
|
|
12008
|
+
this.markDirty();
|
|
11874
12009
|
return;
|
|
11875
12010
|
}
|
|
11876
|
-
|
|
11877
|
-
|
|
11878
|
-
|
|
11879
|
-
|
|
11880
|
-
|
|
11881
|
-
|
|
12011
|
+
if (direction < 0)
|
|
12012
|
+
this.historyBack();
|
|
12013
|
+
else
|
|
12014
|
+
this.historyForward();
|
|
12015
|
+
}
|
|
12016
|
+
/** Press, motion and release: selection, links and card toggles — the transcript's. */
|
|
12017
|
+
mouseGesture(sgr) {
|
|
12018
|
+
const button = Number(sgr[1]);
|
|
12019
|
+
const x = Number(sgr[2]);
|
|
12020
|
+
const y = Number(sgr[3]);
|
|
12021
|
+
if (sgr[4] === 'M') {
|
|
12022
|
+
// 32 is "motion with the left button held": a drag over a reply. Shift adds 4 to
|
|
12023
|
+
// every button code, and a terminal that still forwards the report while Shift is
|
|
12024
|
+
// down must drag exactly like an unshifted one.
|
|
12025
|
+
if (button === 32 || button === 36) {
|
|
12026
|
+
this.extendMouseSelection(y, x);
|
|
12027
|
+
return;
|
|
11882
12028
|
}
|
|
11883
|
-
if (
|
|
11884
|
-
|
|
11885
|
-
|
|
12029
|
+
if (button === 0 || button === 4) {
|
|
12030
|
+
this.beginMouseSelection(y, x);
|
|
12031
|
+
return;
|
|
11886
12032
|
}
|
|
11887
|
-
|
|
11888
|
-
previous = char;
|
|
12033
|
+
return;
|
|
11889
12034
|
}
|
|
12035
|
+
// A release ends either a drag (copy what it covered) or a plain click.
|
|
12036
|
+
if (button === 0 || button === 4)
|
|
12037
|
+
this.endMouseSelection(y, x);
|
|
11890
12038
|
}
|
|
11891
|
-
|
|
11892
|
-
|
|
11893
|
-
|
|
11894
|
-
|
|
11895
|
-
|
|
11896
|
-
|
|
11897
|
-
|
|
11898
|
-
|
|
11899
|
-
|
|
11900
|
-
|
|
11901
|
-
|
|
12039
|
+
/**
|
|
12040
|
+
* The composer/Surface body — the switch and the tail the ladder always had.
|
|
12041
|
+
*
|
|
12042
|
+
* It is shared by two adapters on purpose: its `dialog` guards *are* the Surface's
|
|
12043
|
+
* behaviour for those keys (a borrowed field takes them, a field-less dialog refuses
|
|
12044
|
+
* them), and splitting them is W-3's job. What the Router guarantees is that a Screen
|
|
12045
|
+
* never reaches it, so every key below can only spend itself on a field the reader
|
|
12046
|
+
* has in front of them — the composer's or a borrowed one. The unreachable second
|
|
12047
|
+
* Screen gate the old body carried is gone with it (G-6).
|
|
12048
|
+
*/
|
|
12049
|
+
composerUnit(unit, moved) {
|
|
12050
|
+
void moved;
|
|
12051
|
+
// A run of text is split and re-judged per code point; a single one — ASCII, CJK, or
|
|
12052
|
+
// an astral character the Reader framed as one unit — falls through to the switch and
|
|
12053
|
+
// its owner. Counting UTF-16 code units here sent `🚀` back into this method forever
|
|
12054
|
+
// (W-3b.1b), which is why the test is by code point.
|
|
12055
|
+
if (carriesMultipleCodePoints(unit)) {
|
|
12056
|
+
this.handlePlainText(unit);
|
|
11902
12057
|
return;
|
|
11903
12058
|
}
|
|
11904
|
-
switch (
|
|
12059
|
+
switch (unit) {
|
|
11905
12060
|
case '\x1b':
|
|
11906
12061
|
this.handleEscape();
|
|
11907
12062
|
return;
|
|
@@ -11924,16 +12079,11 @@ export class SshTui {
|
|
|
11924
12079
|
// exit semantics of its own, and falling through to the process exit left a
|
|
11925
12080
|
// dialog on screen killing the session it was waiting for.
|
|
11926
12081
|
case '\x04':
|
|
11927
|
-
if (this.dialog === undefined
|
|
12082
|
+
if (this.dialog === undefined)
|
|
11928
12083
|
void this.requestExit(0);
|
|
11929
12084
|
return;
|
|
11930
12085
|
case '\x0c':
|
|
11931
|
-
this.
|
|
11932
|
-
this.lastChromeKey = '';
|
|
11933
|
-
this.lastPaintWidth = 0;
|
|
11934
|
-
this.lastPaintHeight = 0;
|
|
11935
|
-
this.dirty = true;
|
|
11936
|
-
this.render();
|
|
12086
|
+
this.repaintNow();
|
|
11937
12087
|
return;
|
|
11938
12088
|
case '\x01':
|
|
11939
12089
|
if (this.textIsEditable())
|
|
@@ -11965,11 +12115,11 @@ export class SshTui {
|
|
|
11965
12115
|
// under it changed what a later copy would take while the screen showed
|
|
11966
12116
|
// nothing of the sort.
|
|
11967
12117
|
case '\x0e':
|
|
11968
|
-
if (this.dialog === undefined
|
|
12118
|
+
if (this.dialog === undefined)
|
|
11969
12119
|
this.moveFocus(1);
|
|
11970
12120
|
return;
|
|
11971
12121
|
case '\x10':
|
|
11972
|
-
if (this.dialog === undefined
|
|
12122
|
+
if (this.dialog === undefined)
|
|
11973
12123
|
this.moveFocus(-1);
|
|
11974
12124
|
return;
|
|
11975
12125
|
case '\x12':
|
|
@@ -11986,33 +12136,21 @@ export class SshTui {
|
|
|
11986
12136
|
this.markDirty();
|
|
11987
12137
|
return;
|
|
11988
12138
|
}
|
|
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
12139
|
if (this.dialog !== undefined) {
|
|
11997
|
-
this.handleDialogChar(
|
|
12140
|
+
this.handleDialogChar(unit);
|
|
11998
12141
|
return;
|
|
11999
12142
|
}
|
|
12000
|
-
if (
|
|
12001
|
-
|
|
12002
|
-
this.stepSearch(1);
|
|
12143
|
+
if (unit === '\x07') {
|
|
12144
|
+
this.stepSearch(1);
|
|
12003
12145
|
return;
|
|
12004
12146
|
}
|
|
12005
|
-
if (
|
|
12006
|
-
if (this.dialog !== undefined)
|
|
12007
|
-
return;
|
|
12147
|
+
if (unit === '\x1f') {
|
|
12008
12148
|
this.input = '/find ';
|
|
12009
12149
|
this.cursor = this.input.length;
|
|
12010
12150
|
this.markDirty();
|
|
12011
12151
|
return;
|
|
12012
12152
|
}
|
|
12013
|
-
if (
|
|
12014
|
-
if (this.dialog !== undefined)
|
|
12015
|
-
return;
|
|
12153
|
+
if (unit === '\t') {
|
|
12016
12154
|
if (this.suggestionsVisible()) {
|
|
12017
12155
|
const selected = this.commandSuggestions[this.suggestionIndex];
|
|
12018
12156
|
if (selected !== undefined) {
|
|
@@ -12029,14 +12167,154 @@ export class SshTui {
|
|
|
12029
12167
|
this.markDirty();
|
|
12030
12168
|
return;
|
|
12031
12169
|
}
|
|
12032
|
-
if (
|
|
12170
|
+
if (unit >= ' ' && unit !== '\x7f') {
|
|
12033
12171
|
if (!this.textIsEditable())
|
|
12034
12172
|
return;
|
|
12035
12173
|
this.leaveHistoryBrowse();
|
|
12036
|
-
this.insertIntoField(
|
|
12174
|
+
this.insertIntoField(unit);
|
|
12037
12175
|
this.markDirty();
|
|
12038
12176
|
}
|
|
12039
12177
|
}
|
|
12178
|
+
/**
|
|
12179
|
+
* Ctrl+L's repaint, in one place for the owners that share it (G-6).
|
|
12180
|
+
*
|
|
12181
|
+
* The Screen has its own implementation (`screenNeedsFullPaint`, because it repaints
|
|
12182
|
+
* a Screen's frame); this one drops the cached rows so the next full frame is rebuilt
|
|
12183
|
+
* from scratch. W-2 does not merge them — only a test that proves both owners repaint
|
|
12184
|
+
* identically may (AD-20 §6) — but it does give the composer's copy a name.
|
|
12185
|
+
*/
|
|
12186
|
+
repaintNow() {
|
|
12187
|
+
this.lastPaintRows = [];
|
|
12188
|
+
this.lastChromeKey = '';
|
|
12189
|
+
this.lastPaintWidth = 0;
|
|
12190
|
+
this.lastPaintHeight = 0;
|
|
12191
|
+
this.dirty = true;
|
|
12192
|
+
this.render();
|
|
12193
|
+
}
|
|
12194
|
+
/**
|
|
12195
|
+
* Insert pasted text into the field that owns the keyboard; CR/LF are literal
|
|
12196
|
+
* newlines.
|
|
12197
|
+
*
|
|
12198
|
+
* A paste is text input, so it goes through the same ownership question ordinary
|
|
12199
|
+
* typing does: into the composer's draft when the composer owns the keyboard, and
|
|
12200
|
+
* into a borrowed field when a surface that has one does. A surface without a
|
|
12201
|
+
* text field (a confirmation, a menu, a report) takes no paste at all — the
|
|
12202
|
+
* alternative was writing the composer's hidden draft behind it, which is how a
|
|
12203
|
+
* paste into an approval dialog could silently rewrite the message being written.
|
|
12204
|
+
*
|
|
12205
|
+
* A **Screen** is that same question with a different answer, and it is asked
|
|
12206
|
+
* first because `textIsEditable()` only ever answers for the composer's field: the
|
|
12207
|
+
* setup Screen keeps a field of its own, which the composer's helpers cannot see
|
|
12208
|
+
* and must never be handed (B2.6 §4).
|
|
12209
|
+
*/
|
|
12210
|
+
handlePasteText(text) {
|
|
12211
|
+
const normalized = text.replaceAll('\r\n', '\n').replaceAll('\r', '\n');
|
|
12212
|
+
if (normalized === '')
|
|
12213
|
+
return;
|
|
12214
|
+
if (this.screen !== undefined) {
|
|
12215
|
+
this.pasteIntoScreen(normalized);
|
|
12216
|
+
return;
|
|
12217
|
+
}
|
|
12218
|
+
if (!this.textIsEditable())
|
|
12219
|
+
return;
|
|
12220
|
+
this.leaveHistoryBrowse();
|
|
12221
|
+
const current = this.fieldText();
|
|
12222
|
+
const at = this.fieldCursor();
|
|
12223
|
+
this.setField(`${current.slice(0, at)}${normalized}${current.slice(at)}`, at + normalized.length);
|
|
12224
|
+
if (this.borrowedText === undefined) {
|
|
12225
|
+
const cols = Math.max(10, this.screenColumns());
|
|
12226
|
+
const lineWidth = Math.max(1, cols - 2);
|
|
12227
|
+
if (normalized.includes('\n') || displayWidth(this.fieldText()) > lineWidth)
|
|
12228
|
+
this.inputFolded = true;
|
|
12229
|
+
}
|
|
12230
|
+
this.markDirty();
|
|
12231
|
+
}
|
|
12232
|
+
/**
|
|
12233
|
+
* Give a paste to the field the Screen owns, or to nobody.
|
|
12234
|
+
*
|
|
12235
|
+
* The setup Screen is the only Screen with a field, and it is the one place a
|
|
12236
|
+
* paste matters most: an API key, a base URL and a model list are all copied from
|
|
12237
|
+
* somewhere else, and a first run that could only be typed by hand is the
|
|
12238
|
+
* difference between a working session and an empty one. A step without a field —
|
|
12239
|
+
* the model pickers, the confirmation — is not a text field, so the paste is
|
|
12240
|
+
* dropped exactly as a menu drops it: a paste is text, never an answer.
|
|
12241
|
+
*/
|
|
12242
|
+
pasteIntoScreen(text) {
|
|
12243
|
+
const state = this.onboarding;
|
|
12244
|
+
if (this.screen?.kind !== 'setup' || state === undefined || state.saving)
|
|
12245
|
+
return;
|
|
12246
|
+
if (!SETUP_FIELD_STEPS.has(state.step))
|
|
12247
|
+
return;
|
|
12248
|
+
// The field is drawn on one row, so it takes the value and not the line breaks
|
|
12249
|
+
// around it: a key copied off a web page carries the newline that ended its
|
|
12250
|
+
// line, and a pasted list or `.env` block means the breaks as separators. Both
|
|
12251
|
+
// are folded here rather than inserted literally, which would leave the field
|
|
12252
|
+
// holding text no step can parse and no row can draw.
|
|
12253
|
+
const flat = text.split('\n').map(part => part.trim()).filter(part => part !== '').join(' ');
|
|
12254
|
+
if (flat === '')
|
|
12255
|
+
return;
|
|
12256
|
+
this.setupInsert(state, flat);
|
|
12257
|
+
this.markDirty();
|
|
12258
|
+
}
|
|
12259
|
+
/**
|
|
12260
|
+
* Whether the keys that edit text belong to a field right now.
|
|
12261
|
+
*
|
|
12262
|
+
* The composer always has one; an open surface only has one when it borrowed it
|
|
12263
|
+
* (a free-text answer). Everything else — a confirmation, a menu, the inspect
|
|
12264
|
+
* overlay, every Screen — owns the keyboard without owning the composer's text,
|
|
12265
|
+
* and those keys must do nothing rather than reach the draft behind the surface.
|
|
12266
|
+
* A Screen is included by name: the setup Screen has a field of its own, and it
|
|
12267
|
+
* is reached through the Screen's own keys, never through the composer's.
|
|
12268
|
+
*/
|
|
12269
|
+
textIsEditable() {
|
|
12270
|
+
if (this.screen !== undefined)
|
|
12271
|
+
return false;
|
|
12272
|
+
return this.dialog === undefined || this.borrowedText !== undefined;
|
|
12273
|
+
}
|
|
12274
|
+
handlePlainText(text) {
|
|
12275
|
+
// Fallback for terminals without bracketed paste: a burst of multiple line
|
|
12276
|
+
// breaks in one chunk is a paste, not repeated Enter presses.
|
|
12277
|
+
if (InputReader.looksLikePasteBurst(text)) {
|
|
12278
|
+
this.dispatchPaste(text);
|
|
12279
|
+
return;
|
|
12280
|
+
}
|
|
12281
|
+
// A text unit is delivered one character at a time and each character is routed
|
|
12282
|
+
// again — that is what lets ownership change inside one unit (a Surface answers `y`
|
|
12283
|
+
// and the composer takes the `es` behind it, W-2). A Screen is the one owner that
|
|
12284
|
+
// does not work that way: it owns the read it was sent, and the read-level guard in
|
|
12285
|
+
// `handleInputText` drops the units after it closes. This is the same rule one level
|
|
12286
|
+
// down, for the units that carry several characters: without it a character that
|
|
12287
|
+
// closes the Screen — the `ESC` of a paste tail, a `q`, an Enter — ended the Screen
|
|
12288
|
+
// and the rest of the same unit was typed into the composer behind it (W-3a.1).
|
|
12289
|
+
const dismissals = this.screenDismissals;
|
|
12290
|
+
let previous = '';
|
|
12291
|
+
for (const char of text) {
|
|
12292
|
+
if (this.screenDismissals !== dismissals)
|
|
12293
|
+
return;
|
|
12294
|
+
// Windows terminals may deliver Enter as CRLF; consume only the first half.
|
|
12295
|
+
if (char === '\n' && previous === '\r') {
|
|
12296
|
+
previous = char;
|
|
12297
|
+
continue;
|
|
12298
|
+
}
|
|
12299
|
+
if (char === '\r' && previous === '\n') {
|
|
12300
|
+
previous = char;
|
|
12301
|
+
continue;
|
|
12302
|
+
}
|
|
12303
|
+
this.handleChar(char);
|
|
12304
|
+
previous = char;
|
|
12305
|
+
}
|
|
12306
|
+
}
|
|
12307
|
+
/**
|
|
12308
|
+
* One character, from a read or from the typing loop: the Router decides, again.
|
|
12309
|
+
*
|
|
12310
|
+
* It is asked per character rather than per read because ownership can change inside
|
|
12311
|
+
* one read — a Surface that answers the first character of `yes` is gone for the rest
|
|
12312
|
+
* (pinned by the `yes-typed-at-dialog` trace). A Screen never reaches the body below:
|
|
12313
|
+
* the Router answered `screen` and the Screen adapter took the character.
|
|
12314
|
+
*/
|
|
12315
|
+
handleChar(char) {
|
|
12316
|
+
this.dispatch(this.charKind(char), char, this.keymap.sequences.get(char));
|
|
12317
|
+
}
|
|
12040
12318
|
/**
|
|
12041
12319
|
* Run one rebindable action.
|
|
12042
12320
|
* @param action - the action the pressed key was moved to.
|
|
@@ -12044,11 +12322,14 @@ export class SshTui {
|
|
|
12044
12322
|
*/
|
|
12045
12323
|
runKeyAction(action) {
|
|
12046
12324
|
switch (action) {
|
|
12325
|
+
// Only reachable with nothing layered over the workspace (the ladder asks this
|
|
12326
|
+
// table first thing, and a Screen returns before it), so the target is the
|
|
12327
|
+
// transcript — named here rather than re-derived inside a shared scroller.
|
|
12047
12328
|
case 'pageUp':
|
|
12048
|
-
this.
|
|
12329
|
+
this.scrollWorkspace(Math.max(3, Math.floor(this.screenRows() / 2)));
|
|
12049
12330
|
return true;
|
|
12050
12331
|
case 'pageDown':
|
|
12051
|
-
this.
|
|
12332
|
+
this.scrollWorkspace(-Math.max(3, Math.floor(this.screenRows() / 2)));
|
|
12052
12333
|
return true;
|
|
12053
12334
|
case 'toggleCard':
|
|
12054
12335
|
this.toggleCollapsible();
|
|
@@ -12864,7 +13145,7 @@ export class SshTui {
|
|
|
12864
13145
|
this.setupMessage(state, 'system', t('onboard.baseSaved', { path: displayDshPath('settings.yaml') }));
|
|
12865
13146
|
}
|
|
12866
13147
|
if (saved) {
|
|
12867
|
-
this.onboardDone(t('onboard.officialDone', { model }));
|
|
13148
|
+
await this.onboardDone(t('onboard.officialDone', { model }));
|
|
12868
13149
|
}
|
|
12869
13150
|
}
|
|
12870
13151
|
else {
|
|
@@ -13037,7 +13318,7 @@ export class SshTui {
|
|
|
13037
13318
|
this.onSelectionChanged?.(selection);
|
|
13038
13319
|
await this.rememberRoute(selection);
|
|
13039
13320
|
await this.syncSubagentToProvider(ownerId, state.models, previousParentProvider !== ownerId);
|
|
13040
|
-
this.onboardDone(t('onboard.customDone', { id: ownerId, model }));
|
|
13321
|
+
await this.onboardDone(t('onboard.customDone', { id: ownerId, model }));
|
|
13041
13322
|
}
|
|
13042
13323
|
}
|
|
13043
13324
|
}
|
|
@@ -13081,8 +13362,40 @@ export class SshTui {
|
|
|
13081
13362
|
* answer: `command-status` routes to the transcript, exactly like the boot line
|
|
13082
13363
|
* that asked for the setup in the first place.
|
|
13083
13364
|
*/
|
|
13084
|
-
onboardDone(text) {
|
|
13365
|
+
async onboardDone(text) {
|
|
13366
|
+
const profile = profileFromArgv();
|
|
13367
|
+
let repairNote;
|
|
13368
|
+
try {
|
|
13369
|
+
// The wizard wrote settings and the model selection through the profile
|
|
13370
|
+
// patch. Only after those writes are complete does the agent-plane repair
|
|
13371
|
+
// run, so it cannot be undone by the wizard's own patch update.
|
|
13372
|
+
const repaired = await this.ensureProfileAgentPlane(profile);
|
|
13373
|
+
if (repaired === 'written') {
|
|
13374
|
+
repairNote = t('onboard.agentPlaneAdded', {
|
|
13375
|
+
path: rosterPatchPath(resolveDshHome(), profile),
|
|
13376
|
+
profile,
|
|
13377
|
+
});
|
|
13378
|
+
}
|
|
13379
|
+
else if (this.settingsGeneration === 'forms' && !this.agentPlaneReady()) {
|
|
13380
|
+
// The rows are there but this process was composed before them (or a
|
|
13381
|
+
// row failed to activate). Saying "saved" alone would hide that the
|
|
13382
|
+
// tools are still absent from this session.
|
|
13383
|
+
repairNote = t('onboard.agentPlaneRestart', {
|
|
13384
|
+
path: rosterPatchPath(resolveDshHome(), profile),
|
|
13385
|
+
profile,
|
|
13386
|
+
});
|
|
13387
|
+
}
|
|
13388
|
+
}
|
|
13389
|
+
catch (error) {
|
|
13390
|
+
repairNote = t('onboard.agentPlaneFailed', {
|
|
13391
|
+
error: errorChain(error),
|
|
13392
|
+
path: rosterPatchPath(resolveDshHome(), profile),
|
|
13393
|
+
});
|
|
13394
|
+
}
|
|
13085
13395
|
this.pushRow(represent('command-status', { kind: 'system', text }));
|
|
13396
|
+
if (repairNote !== undefined) {
|
|
13397
|
+
this.pushRow(represent('runtime-warning', { kind: 'system', text: repairNote }));
|
|
13398
|
+
}
|
|
13086
13399
|
this.markDirty();
|
|
13087
13400
|
}
|
|
13088
13401
|
/** Store one credential, falling back to a launch-environment override on shadow/absence. */
|
|
@@ -13452,10 +13765,14 @@ export class SshTui {
|
|
|
13452
13765
|
// *what* went to the clipboard, and a second press of the copy key must be
|
|
13453
13766
|
// idempotent. Clearing it here made the next `/copy` silently fall back to
|
|
13454
13767
|
// "the latest reply" — a different row, with nothing on screen to say so.
|
|
13455
|
-
|
|
13456
|
-
this
|
|
13457
|
-
this.
|
|
13458
|
-
|
|
13768
|
+
//
|
|
13769
|
+
// **The composer's draft is not this function's to write** (input-ownership
|
|
13770
|
+
// AD-20, F-2). It used to be: `this.input = ''` + caret + fold + the history
|
|
13771
|
+
// browse reset sat here from the 0.5.5 era, when `/copy` was *text submitted
|
|
13772
|
+
// from the composer* and clearing it was housekeeping. The command line is
|
|
13773
|
+
// `runCommand`'s to consume (it clears the composer for every command), and the
|
|
13774
|
+
// key, a drag, a link click and a Screen's copy are not commands at all — a
|
|
13775
|
+
// reader who typed half a message and then copied a reply lost it.
|
|
13459
13776
|
this.write(osc52Clipboard(text));
|
|
13460
13777
|
this.pushRow(represent('copy-feedback', { kind: 'system', text: notice }));
|
|
13461
13778
|
// The write is a request the terminal may ignore: VTE (GNOME/XFCE/…) has
|
|
@@ -13592,13 +13909,7 @@ export class SshTui {
|
|
|
13592
13909
|
* inspect overlay owns the keyboard and takes no scroll at all: the transcript
|
|
13593
13910
|
* behind a dialog must not move.
|
|
13594
13911
|
*/
|
|
13595
|
-
|
|
13596
|
-
if (this.screen !== undefined) {
|
|
13597
|
-
this.scrollScreen(up);
|
|
13598
|
-
return;
|
|
13599
|
-
}
|
|
13600
|
-
if (this.dialog !== undefined)
|
|
13601
|
-
return;
|
|
13912
|
+
scrollWorkspace(up) {
|
|
13602
13913
|
if (this.paintTailBudget > 0) {
|
|
13603
13914
|
this.paintTailBudget = 0;
|
|
13604
13915
|
this.forceFullPaint = true;
|
|
@@ -13987,7 +14298,7 @@ export class SshTui {
|
|
|
13987
14298
|
break;
|
|
13988
14299
|
}
|
|
13989
14300
|
const targets = ids.map(id => SessionId(id));
|
|
13990
|
-
void subagents
|
|
14301
|
+
void drainChildSubagents(subagents, this.agent, targets).then(() => {
|
|
13991
14302
|
this.pushRow(represent('subagent-notice', { kind: 'system', text: t('sub.killRequested', { ids: ids.join(', ') }) }));
|
|
13992
14303
|
this.markDirty();
|
|
13993
14304
|
}).catch((error) => {
|
|
@@ -14167,13 +14478,21 @@ export class SshTui {
|
|
|
14167
14478
|
this.inputFolded = false;
|
|
14168
14479
|
this.markDirty();
|
|
14169
14480
|
}
|
|
14170
|
-
/**
|
|
14481
|
+
/**
|
|
14482
|
+
* Grapheme cluster immediately before `cursor`; cursor-internal positions delete it whole.
|
|
14483
|
+
*
|
|
14484
|
+
* The cluster is measured in the field that owns the keyboard, like `backspace` and
|
|
14485
|
+
* `deleteAtCursor` above, which is what makes their ranges mean the same thing as the
|
|
14486
|
+
* cursor they were handed (W-3b.1b): segmenting the *draft* while editing a borrowed
|
|
14487
|
+
* answer gave offsets from another string, so a borrowed `a🚀` lost one surrogate and
|
|
14488
|
+
* kept the caret inside the pair.
|
|
14489
|
+
*/
|
|
14171
14490
|
graphemeBefore(cursor) {
|
|
14172
14491
|
if (cursor <= 0)
|
|
14173
14492
|
return { start: 0, end: 0 };
|
|
14174
14493
|
let previousStart = 0;
|
|
14175
14494
|
let previousEnd = 0;
|
|
14176
|
-
for (const segment of GRAPHEME_SEGMENTER.segment(this.
|
|
14495
|
+
for (const segment of GRAPHEME_SEGMENTER.segment(this.fieldText())) {
|
|
14177
14496
|
const start = segment.index;
|
|
14178
14497
|
const end = start + segment.segment.length;
|
|
14179
14498
|
if (cursor === start)
|
|
@@ -14185,9 +14504,9 @@ export class SshTui {
|
|
|
14185
14504
|
}
|
|
14186
14505
|
return { start: previousStart, end: previousEnd };
|
|
14187
14506
|
}
|
|
14188
|
-
/** Grapheme cluster containing or following `cursor
|
|
14507
|
+
/** Grapheme cluster containing or following `cursor`, in the field that owns the keyboard. */
|
|
14189
14508
|
graphemeAfter(cursor) {
|
|
14190
|
-
for (const segment of GRAPHEME_SEGMENTER.segment(this.
|
|
14509
|
+
for (const segment of GRAPHEME_SEGMENTER.segment(this.fieldText())) {
|
|
14191
14510
|
const start = segment.index;
|
|
14192
14511
|
const end = start + segment.segment.length;
|
|
14193
14512
|
if (cursor === start || (cursor > start && cursor < end))
|
|
@@ -14269,8 +14588,19 @@ export class SshTui {
|
|
|
14269
14588
|
this.cursor = this.input.length;
|
|
14270
14589
|
this.markDirty();
|
|
14271
14590
|
}
|
|
14272
|
-
/**
|
|
14591
|
+
/**
|
|
14592
|
+
* Typing while browsing history detaches from the saved item.
|
|
14593
|
+
*
|
|
14594
|
+
* The browse is the **composer's**, so only the composer's own field may end it
|
|
14595
|
+
* (W-3b.3a). Every editing key a Surface borrowed the field for arrives here —
|
|
14596
|
+
* `backspace`, `deleteAtCursor`, `handlePasteText`, and `composerUnit`'s Ctrl+U /
|
|
14597
|
+
* Ctrl+K — and detaching on that path wrote the hidden composer's state from the
|
|
14598
|
+
* visible answer: the recalled entry replaced `historyDraft`, so the unsent draft
|
|
14599
|
+
* the browse was holding could never be reached again with `↓`.
|
|
14600
|
+
*/
|
|
14273
14601
|
leaveHistoryBrowse() {
|
|
14602
|
+
if (this.borrowedText !== undefined)
|
|
14603
|
+
return;
|
|
14274
14604
|
if (this.historyIndex >= 0 && this.historyIndex < this.history.length) {
|
|
14275
14605
|
this.historyIndex = this.history.length;
|
|
14276
14606
|
this.historyDraft = this.input;
|