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/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, formsRowsDeclared, rosterPatchPath, rosterRows, writePatchWithBackup, ALL_ROSTER_ROWS, } from './preset-rows.js';
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, isEscapePrefix, isHangupErrno, linkQualityOf, linkRedrawBudgetMs, parseCursorPositionReply, PICKER_WINDOW, pickerWindowStart, probeTerminalRttMs, releaseHangupSignals, resolvePaintIntervalMs, toolBodyLineLimit, waitUntilIdleOrTimeout, RTT_HISTORY, medianRtt, } from './paint.js';
78
- import { TerminalInputGuard } from './terminal-input.js';
78
+ import { captureHangupSignals, advancePaintedRows, composePaintFrame, frameByteBudget, detectSshSession, formatLinkQualityChip, HANGUP_CANCEL_TIMEOUT_MS, ignoreFurtherHangupSignals, isHangupErrno, linkQualityOf, linkRedrawBudgetMs, PICKER_WINDOW, pickerWindowStart, probeTerminalRttMs, releaseHangupSignals, resolvePaintIntervalMs, toolBodyLineLimit, waitUntilIdleOrTimeout, RTT_HISTORY, medianRtt, } from './paint.js';
79
79
  import { contextPressureAlertText, contextPressureView, describeProviderRoute, fitFooterStatusLine, footerActivity, footerHealthChip, footerIdentityParts, footerSubagentForeign, paintFooterSubagentChip, footerStatsGroups, formatContextPressureChip, runtimeStrip, providerHasQuotaSurface, formatStatusReport, planRouteBadge, shortQuotaPlanName, formatTokens, parseContextPressure, promptPressureTokens, providerUsesLocalOAuth, shouldIdleAutoCompact, } from './footer.js';
80
80
  import { COMMAND_CODE_CREDITS_URL, COMMAND_CODE_SUBSCRIPTIONS_URL, COMMAND_CODE_USAGE_URL, commandCodePeriodStart, commandCodeSourceFor, crossedQuotaThresholds, DEEPSEEK_PUBLIC_BASE_URL, formatAccountBalance, formatFooterBalance, formatQuotaSnapshot, joinUrl, OPENAI_COMPAT_BALANCE_PATHS, OPENCODE_GO_USAGE_URL, OPENCODE_ZEN_BASE_URL, openCodeApiErrorMessage, openCodeSourceFor, parseCommandCodeQuota, parseDeepSeekBalance, parseOpenAiCompatibleBalance, parseOpenCodeGoQuota, parseSuperGrokBilling, preferredQuotaWindow, quotaAlertText, quotaRefreshEverySteps, reasoningEffortsForDefault, SUPERGROK_BILLING_URL, tightestQuotaWindow, } from './quota.js';
81
81
  import { appendSubagentLog, foldSubagentUserLog, applyTurnEndToPlan, buildSubagentHeader, cardCategoryLabel, cardCategoryOf, clipSubagentActivity, compactionHeaderText, formatCompactCommandError, isPromptInjectionMessage, matchTranscriptRows, parseFindQuery, planCloseNudgeText, planMarkdownFromArgs, planDockNote, planIsLive, planTitleFromMarkdown, promptInjectionSources, promptInjectionTitle, describeSubagentFailure, subagentChipSummary, subagentDisplayName, subagentInspectLines, subagentRowFromSpawnTool, todoItemKind, askQuestions, todoProgressLabel, TODO_STATUS_MARK, } from './plan.js';
@@ -448,84 +448,27 @@ const QUOTA_RETRY_MS = 15_000;
448
448
  * screen's own tests have one source: the *order* is the product decision (B2.6 §3
449
449
  * keeps it unchanged), and this is where it is written down.
450
450
  */
451
- const SETUP_STEPS = ['provider', 'id', 'base-url', 'key', 'models', 'models-pick', 'model-default', 'context', 'confirm'];
452
- /** The steps that show a text field (the others are pickers or the confirmation). */
453
- const SETUP_FIELD_STEPS = new Set(['provider', 'id', 'base-url', 'key', 'models', 'context']);
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 data chunk is a paste rather than keystrokes.
454
+ * Whether one unit carries more than one Unicode code point.
456
455
  *
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.
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 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
- }
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
- * Cursor-position replies removed from the relay's stdin stream. A launcher
1516
- * from an older release (or a reply that raced its own probe) would otherwise
1517
- * type `[17;1R` into the prompt or cancel a dialog with a bare ESC.
1454
+ * The input Reader (AD-21): decoding, reply filtering, escape assembly, paste
1455
+ * framing and unit classification live there; the frames come back through
1456
+ * `handleInputText` / `consumeFrame`, which own the routing and the semantics.
1518
1457
  */
1519
- inputGuard = new TerminalInputGuard(text => this.handleInputText(text));
1458
+ reader = new InputReader({
1459
+ onRead: text => this.handleInputText(text),
1460
+ onFrame: frame => this.consumeFrame(frame),
1461
+ });
1462
+ /**
1463
+ * The Router (AD-22, W-2): the one place a unit's owner is decided.
1464
+ *
1465
+ * `ownershipView` is the read-only snapshot it answers from — one object, rewritten
1466
+ * per decision, so a keystroke allocates nothing (W-2 is a hot-path migration).
1467
+ */
1468
+ router = new InputRouter();
1469
+ ownershipView = {
1470
+ screenKind: undefined,
1471
+ surfaceKind: undefined,
1472
+ surfaceHasField: false,
1473
+ composerEmpty: true,
1474
+ workspaceCards: false,
1475
+ };
1520
1476
  thinkingStartedAt;
1521
1477
  waitStartedAt;
1522
1478
  /**
@@ -1546,6 +1502,15 @@ export class SshTui {
1546
1502
  cwdChipRow;
1547
1503
  /** Set when a card expand/collapse moves chrome; next paint full-redraws. */
1548
1504
  forceFullPaint = false;
1505
+ /**
1506
+ * How many times the Screen has been dismissed.
1507
+ *
1508
+ * A read is not a key: its units are delivered one at a time, and a Screen owns the
1509
+ * read it was sent — once it is gone the rest is dropped (0.8.3's rule, W-3a keeps
1510
+ * it). Counting dismissals rather than comparing the Screen object keeps that check
1511
+ * out of the owner-read ratchet, which exists to stop new *ownership* reads.
1512
+ */
1513
+ screenDismissals = 0;
1549
1514
  /** Highest row the last budgeted frame could not paint, resumed next tick. */
1550
1515
  paintResume;
1551
1516
  /** How many times a display has come back after a drop in this Host's life. */
@@ -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.escapeTimer = undefined;
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: Object.keys(providers),
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
- const pos = cursorVisualPosition(inputView.text, this.cursor, inputTextWidth);
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
- // The identity row is composed *after* the strip, because whether it needs to
5825
- // carry the echo depends on what the strip managed to fit (see below).
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
- : missingEcho === undefined
5829
- ? fitFooterStatusLine('', identity, Math.max(1, width))
5830
- : truncateToWidth(missingEcho, Math.max(1, width));
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
- * `/mode fix`: write the roster row into this profile's user patch layer.
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
- * The write only takes effect on the next launch — the loader composes the
10945
- * patch tree once at boot — so the report says so instead of pretending the
10946
- * running session gained a roster.
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 rows, so the chip reports those instead.
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
- ? !formsRowsDeclared(resolveDshHome(), profileFromArgv())
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 generation = this.settingsGeneration;
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
- const decoded = this.decoder.write(chunk);
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. It is
11361
- // asked *before* the split below, because splitting is what would turn one paste
11362
- // into a step's worth of Enters.
11363
- if (looksLikePasteBurst(combined)) {
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.escapeBuffer + text;
11529
- this.escapeBuffer = '';
11530
- if (this.escapeTimer !== undefined) {
11531
- clearTimeout(this.escapeTimer);
11532
- this.escapeTimer = undefined;
11533
- }
11534
- // Bracketed paste: terminals wrap pasted content in \x1b[200~ ... \x1b[201~.
11535
- // While inside a paste, CR/LF are literal input characters rather than
11536
- // submit, so copying a multi-line error message arrives as one message.
11537
- if (this.inPaste || combined.includes('\x1b[200~') || combined.includes('\x1b[201~')) {
11538
- this.processPasteChunk(combined);
11539
- return;
11540
- }
11541
- // A key the user moved, and a key their move displaced. Consulted before any
11542
- // routing so a plain control byte reaches it as readily as a CSI sequence;
11543
- // everything else keeps the handling below, which is what makes the defaults
11544
- // unchanged by construction.
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
- // A Screen replaces the workspace and owns every key outright. There is no
11547
- // composer behind it, no transcript to fall through to and no Surface it could
11548
- // share with: one gate, instead of the ten `dialog?.kind === 'inspect'` checks
11549
- // this replaced (B2.1).
11550
- if (this.screen !== undefined) {
11551
- this.handleScreenInput(combined, moved);
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
- const escape = /^\x1b\[([A-D])$/u;
11563
- const match = combined.match(escape);
11564
- if (match !== null) {
11565
- switch (match[1]) {
11566
- case 'A':
11567
- if (this.moveQuestionCursor(-1)) {
11568
- return;
11569
- return;
11570
- return;
11571
- }
11572
- else if (this.dialog !== undefined) {
11573
- // A surface owns the keyboard: an arrow it does not use is not the
11574
- // transcript's. Falling through moved the selection behind the dialog,
11575
- // and with a non-empty field it rewrote the field from history.
11576
- return;
11577
- }
11578
- else if (this.suggestionsVisible()) {
11579
- this.suggestionIndex = Math.max(0, this.suggestionIndex - 1);
11580
- this.markDirty();
11581
- }
11582
- else if (this.fieldText() === '' && this.collapsibleRows().length > 0) {
11583
- this.moveFocus(-1);
11584
- }
11585
- else {
11586
- this.historyBack();
11587
- }
11588
- return;
11589
- case 'B':
11590
- if (this.moveQuestionCursor(1)) {
11591
- return;
11592
- return;
11593
- return;
11594
- }
11595
- else if (this.dialog !== undefined) {
11596
- return;
11597
- }
11598
- else if (this.suggestionsVisible()) {
11599
- this.suggestionIndex = Math.min(this.commandSuggestions.length - 1, this.suggestionIndex + 1);
11600
- this.markDirty();
11601
- }
11602
- else if (this.fieldText() === '' && this.collapsibleRows().length > 0) {
11603
- this.moveFocus(1);
11604
- }
11605
- else {
11606
- this.historyForward();
11607
- }
11608
- return;
11609
- case 'C':
11610
- if (this.textIsEditable())
11611
- this.moveCursor(1);
11612
- return;
11613
- case 'D':
11614
- if (this.textIsEditable())
11615
- this.moveCursor(-1);
11616
- return;
11617
- }
11618
- }
11619
- const sgrMouse = /^\x1b\[<(\d+);(\d+);(\d+)([Mm])$/u.exec(combined);
11620
- if (sgrMouse !== null) {
11621
- const button = Number(sgrMouse[1]);
11622
- const x = Number(sgrMouse[2]);
11623
- const y = Number(sgrMouse[3]);
11624
- if (sgrMouse[4] === 'M') {
11625
- if (button === 64) {
11626
- this.scrollActiveSurface(3);
11627
- return;
11628
- }
11629
- if (button === 65) {
11630
- this.scrollActiveSurface(-3);
11631
- return;
11632
- }
11633
- // 32 is "motion with the left button held": a drag over a reply. Shift
11634
- // adds 4 to every button code, and a terminal that still forwards the
11635
- // report while Shift is down (instead of keeping it for its own
11636
- // selection) must drag exactly like an unshifted one — otherwise the
11637
- // gesture the user was told to fall back on does nothing here.
11638
- if (button === 32 || button === 36) {
11639
- this.extendMouseSelection(y, x);
11640
- return;
11641
- }
11642
- if (button === 0 || button === 4) {
11643
- this.beginMouseSelection(y, x);
11644
- return;
11645
- }
11646
- return;
11647
- }
11648
- // A release ends either a drag (copy what it covered) or a plain click.
11649
- if (button === 0 || button === 4)
11650
- this.endMouseSelection(y, x);
11693
+ if (this.reader.isDeviceReply(combined)) {
11694
+ this.consumeFrame({ kind: 'reply', sequence: combined });
11651
11695
  return;
11652
11696
  }
11653
- if (combined === '\x1b[5~') {
11654
- this.scrollActiveSurface(Math.max(3, Math.floor(this.screenRows() / 2)));
11655
- return;
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
- if (isEscapePrefix(combined)) {
11690
- this.escapeBuffer = combined;
11691
- this.escapeTimer = setTimeout(() => {
11692
- this.escapeTimer = undefined;
11693
- const pending = this.escapeBuffer;
11694
- this.escapeBuffer = '';
11695
- if (pending === '\x1b') {
11696
- this.handleChar('\x1b');
11697
- }
11698
- else if (pending === '\x1bO') {
11699
- // ESC O without an SS3 final byte is an Alt+O keystroke, not a
11700
- // function key.
11701
- this.handlePlainText('O');
11702
- }
11703
- else if (pending.startsWith('\x1b[') || pending.startsWith('\x1bO')) {
11704
- // An escape sequence that never completed: consume it silently
11705
- // instead of treating its ESC byte as a cancel.
11706
- }
11707
- else if (pending !== '') {
11708
- this.handlePlainText(pending);
11709
- }
11710
- }, 60);
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.input = '/find ';
11746
- this.cursor = this.input.length;
11747
- this.markDirty();
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.handlePlainText(combined);
11766
+ this.dispatchWith(owner, combined, moved);
11755
11767
  }
11756
- /** Handle one data chunk that may contain bracketed-paste markers. */
11757
- processPasteChunk(combined) {
11758
- let index = 0;
11759
- while (index < combined.length) {
11760
- if (combined.startsWith('\x1b[200~', index)) {
11761
- this.inPaste = true;
11762
- index += 6;
11763
- continue;
11764
- }
11765
- if (combined.startsWith('\x1b[201~', index)) {
11766
- this.inPaste = false;
11767
- index += 6;
11768
- continue;
11769
- }
11770
- let end = index;
11771
- while (end < combined.length
11772
- && !combined.startsWith('\x1b[200~', end)
11773
- && !combined.startsWith('\x1b[201~', end)) {
11774
- end += 1;
11775
- }
11776
- if (end > index) {
11777
- const part = combined.slice(index, end);
11778
- if (this.inPaste)
11779
- this.handlePasteText(part);
11780
- else
11781
- this.handlePlainText(part);
11782
- index = end;
11783
- }
11784
- else {
11785
- index += 1;
11786
- }
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
- * Insert pasted text into the field that owns the keyboard; CR/LF are literal
11791
- * newlines.
11839
+ * A paste payload: the Router decides, the paste path executes.
11792
11840
  *
11793
- * A paste is text input, so it goes through the same ownership question ordinary
11794
- * typing does: into the composer's draft when the composer owns the keyboard, and
11795
- * into a borrowed field when a surface that has one does. A surface without a
11796
- * text field (a confirmation, a menu, a report) takes no paste at all — the
11797
- * alternative was writing the composer's hidden draft behind it, which is how a
11798
- * paste into an approval dialog could silently rewrite the message being written.
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
- * A **Screen** is that same question with a different answer, and it is asked
11801
- * first because `textIsEditable()` only ever answers for the composer's field: the
11802
- * setup Screen keeps a field of its own, which the composer's helpers cannot see
11803
- * and must never be handed (B2.6 §4).
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
- handlePasteText(text) {
11806
- const normalized = text.replaceAll('\r\n', '\n').replaceAll('\r', '\n');
11807
- if (normalized === '')
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
- if (this.screen !== undefined) {
11810
- this.pasteIntoScreen(normalized);
11881
+ }
11882
+ if (unit === '\x1b[B') {
11883
+ this.moveQuestionCursor(1);
11811
11884
  return;
11812
11885
  }
11813
- if (!this.textIsEditable())
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
- this.markDirty();
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
- * Give a paste to the field the Screen owns, or to nobody.
11914
+ * The Composer adapter.
11829
11915
  *
11830
- * The setup Screen is the only Screen with a field, and it is the one place a
11831
- * paste matters most: an API key, a base URL and a model list are all copied from
11832
- * somewhere else, and a first run that could only be typed by hand is the
11833
- * difference between a working session and an empty one. A step without a field —
11834
- * the model pickers, the confirmation — is not a text field, so the paste is
11835
- * dropped exactly as a menu drops it: a paste is text, never an answer.
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
- pasteIntoScreen(text) {
11838
- const state = this.onboarding;
11839
- if (this.screen?.kind !== 'setup' || state === undefined || state.saving)
11923
+ ownerComposer(unit, moved) {
11924
+ if (unit === '\x1b[A') {
11925
+ this.arrowHistory(-1);
11840
11926
  return;
11841
- if (!SETUP_FIELD_STEPS.has(state.step))
11927
+ }
11928
+ if (unit === '\x1b[B') {
11929
+ this.arrowHistory(1);
11842
11930
  return;
11843
- // The field is drawn on one row, so it takes the value and not the line breaks
11844
- // around it: a key copied off a web page carries the newline that ended its
11845
- // line, and a pasted list or `.env` block means the breaks as separators. Both
11846
- // are folded here rather than inserted literally, which would leave the field
11847
- // holding text no step can parse and no row can draw.
11848
- const flat = text.split('\n').map(part => part.trim()).filter(part => part !== '').join(' ');
11849
- if (flat === '')
11931
+ }
11932
+ if (unit === '\x1b[C') {
11933
+ if (this.textIsEditable())
11934
+ this.moveCursor(1);
11850
11935
  return;
11851
- this.setupInsert(state, flat);
11852
- this.markDirty();
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
- * Whether the keys that edit text belong to a field right now.
11856
- *
11857
- * The composer always has one; an open surface only has one when it borrowed it
11858
- * (a free-text answer). Everything else — a confirmation, a menu, the inspect
11859
- * overlay, every Screen — owns the keyboard without owning the composer's text,
11860
- * and those keys must do nothing rather than reach the draft behind the surface.
11861
- * A Screen is included by name: the setup Screen has a field of its own, and it
11862
- * is reached through the Screen's own keys, never through the composer's.
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
- textIsEditable() {
11865
- if (this.screen !== undefined)
11866
- return false;
11867
- return this.dialog === undefined || this.borrowedText !== undefined;
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
- handlePlainText(text) {
11870
- // Fallback for terminals without bracketed paste: a burst of multiple line
11871
- // breaks in one chunk is a paste, not repeated Enter presses.
11872
- if (looksLikePasteBurst(text)) {
11873
- this.handlePasteText(text);
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
- let previous = '';
11877
- for (const char of text) {
11878
- // Windows terminals may deliver Enter as CRLF; consume only the first half.
11879
- if (char === '\n' && previous === '\r') {
11880
- previous = char;
11881
- continue;
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 (char === '\r' && previous === '\n') {
11884
- previous = char;
11885
- continue;
12029
+ if (button === 0 || button === 4) {
12030
+ this.beginMouseSelection(y, x);
12031
+ return;
11886
12032
  }
11887
- this.handleChar(char);
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
- handleChar(char) {
11892
- // A Screen owns every key outright, and the gate comes first for the same reason
11893
- // `handleInputText` puts it first: the switch below spends its keys on the
11894
- // composer — Enter submits the draft, Backspace/Ctrl+U/Ctrl+K edit it, Ctrl+R
11895
- // folds the transcript behind the Screen. A Screen is not a dialog, so none of
11896
- // the `dialog === undefined` guards in that switch ever saw it, and every one of
11897
- // those keys used to reach a draft the reader could not see. The Screen decides
11898
- // what it uses (Enter and Ctrl+C close it, Esc leaves it, the wizard answers with
11899
- // them); anything it does not use is dropped, never forwarded (B2.6 §4).
11900
- if (this.screen !== undefined) {
11901
- this.handleScreenInput(char, this.keymap.sequences.get(char));
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 (char) {
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 && this.screen === undefined)
12082
+ if (this.dialog === undefined)
11928
12083
  void this.requestExit(0);
11929
12084
  return;
11930
12085
  case '\x0c':
11931
- this.lastPaintRows = [];
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 && this.screen === undefined)
12118
+ if (this.dialog === undefined)
11969
12119
  this.moveFocus(1);
11970
12120
  return;
11971
12121
  case '\x10':
11972
- if (this.dialog === undefined && this.screen === 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(char);
12140
+ this.handleDialogChar(unit);
11998
12141
  return;
11999
12142
  }
12000
- if (char === '\x07') {
12001
- if (this.dialog === undefined)
12002
- this.stepSearch(1);
12143
+ if (unit === '\x07') {
12144
+ this.stepSearch(1);
12003
12145
  return;
12004
12146
  }
12005
- if (char === '\x1f') {
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 (char === '\t') {
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 (char >= ' ' && char !== '\x7f') {
12170
+ if (unit >= ' ' && unit !== '\x7f') {
12033
12171
  if (!this.textIsEditable())
12034
12172
  return;
12035
12173
  this.leaveHistoryBrowse();
12036
- this.insertIntoField(char);
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.scrollActiveSurface(Math.max(3, Math.floor(this.screenRows() / 2)));
12329
+ this.scrollWorkspace(Math.max(3, Math.floor(this.screenRows() / 2)));
12049
12330
  return true;
12050
12331
  case 'pageDown':
12051
- this.scrollActiveSurface(-Math.max(3, Math.floor(this.screenRows() / 2)));
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
- this.input = '';
13456
- this.cursor = 0;
13457
- this.inputFolded = false;
13458
- this.leaveHistoryBrowse();
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
- scrollActiveSurface(up) {
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.drainContinuableChildren(this.agent, targets).then(() => {
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
- /** Grapheme cluster immediately before `cursor`; cursor-internal positions delete it whole. */
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.input)) {
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.input)) {
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
- /** Typing while browsing history detaches from the saved item. */
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;