dsh-ssh-tui 0.8.2 → 0.8.3

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 CHANGED
@@ -10,6 +10,30 @@
10
10
 
11
11
  ---
12
12
 
13
+ ## 0.8.3 — 2026-10-13(`latest` + `next`)
14
+
15
+ 发版说明:[`docs/release-notes-0.8.3.md`](release-notes-0.8.3.md)。
16
+ **紧急修复**:三个缺陷都是 0.8.2 把首启向导改成 Screen 时引入的,全部在"首次安装 / `/setup`"这条路上。
17
+
18
+ ### Fixed
19
+
20
+ - **首启向导 / `/setup` 的粘贴**:终端粘贴(Ctrl+V / Cmd+V / 右键,即括号粘贴)此前落到 Screen
21
+ *背后*的工作区草稿里——向导字段一个字都收不到,读者只能手敲 API Key。现在粘贴归字段所有:字段步骤
22
+ 直接落进字段(行尾换行折叠掉,多行按分隔拼接成一行),没有字段的步骤(模型多选、确认)不接收,
23
+ 两种情况下草稿都不再被改写。
24
+ - **走完向导没有退回工作区**:保存成功后 Screen 仍然占着屏,要随便按一个键才退得出去(那个键的兜底
25
+ 才是真正的退场)。现在**保存本身就是退场**,与 Esc 对称;保存失败则**保留向导与错误**(此前错误写在
26
+ 一个立刻被丢弃的状态上:Screen 留着、错误没了、填好的答案也没了)。完成语改为转写行,回到工作区
27
+ 仍可读到。
28
+ - **一次读取里的多个按键**:`text\r` 这样的读取会收下文字却丢掉回车,步骤停住不动。现在按 composer
29
+ 一直以来的规则拆开(转义序列完整、CRLF 一个回车、控制字节各自成键、可打印文本连成一段)。
30
+ - **Screen 打开时的 composer 编辑键**:`Enter` / `Backspace` / `Ctrl+H` / `Ctrl+U` / `Ctrl+K` /
31
+ `Ctrl+A` / `Ctrl+E` / `Ctrl+R` / `Ctrl+T` 不再作用于 `/diag`、`/status`、diff 全览等 Screen 背后
32
+ 那个看不见的草稿(此前 `Enter` 会把草稿当消息发出去,`Ctrl+U` / `Ctrl+K` 会清空或截断它)。
33
+ - **Screen 里的 `Ctrl+L`**:任意 Screen(不只是向导)都能整屏重画,用于清理花屏的终端;此前只有向导认这个键。
34
+
35
+ ---
36
+
13
37
  ## 0.8.2 — 2026-10-05(`latest` + `next`)
14
38
 
15
39
  发版说明:[`docs/release-notes-0.8.2.md`](release-notes-0.8.2.md);候选版说明保留在
@@ -3,7 +3,9 @@
3
3
  > **发布方式:`latest` + `next`。** 上一次发版:**0.8.1**(2026-09-30,`latest` + `next`)。
4
4
  > 本版由 **0.8.2-rc.1**(2026-10-04 只上 `next`)沉淀一周后转正:候选版期间没有收到新的回归报告,
5
5
  > CI 五条腿与全部真 PTY 探针持续全绿,Windows / ConPTY 实机人工验收也已完成(见
6
- > [`checkpoints.md`](checkpoints.md)「Windows 实机轮」)。**内容与 0.8.2-rc.1 完全相同**,
6
+ > [`checkpoints.md`](checkpoints.md)「Windows 实机轮」)。**运行时内容与候选版逐字节相同**(转正后从 registry 分别取回两个 tarball,`lib/` 整目录
7
+ > `diff -r` 无差异);自候选版以来新增的只有 README 与文档(DSH Relay 软品牌那一批)以及
8
+ > `docs/screenshots/reconnect.gif`。
7
9
  > 逐项说明见 [`release-notes-0.8.2-rc.1.md`](release-notes-0.8.2-rc.1.md)。
8
10
 
9
11
  > `dsh-ssh-tui` is now presented as DSH Relay.
@@ -0,0 +1,72 @@
1
+ # 0.8.3 发版说明
2
+
3
+ > **发布方式:`latest` + `next`。** 上一次发版:**0.8.2**(2026-10-05,`latest` + `next`)。
4
+ > 本版是**紧急修复**:0.8.2 把首启向导改成 Screen 之后,向导的输入与退场出了三个缺陷,
5
+ > 都在"首次安装 / `/setup`"这条路上。没有协议变更、没有配置变更、没有数据损坏;
6
+ > 升级即修复,不需要任何迁移动作。
7
+
8
+ ## 头等事:向导收得下粘贴,也走得掉
9
+
10
+ 三个缺陷都是 0.8.2 引入的(0.8.1 的向导借工作区的输入行,所以既收得到粘贴、也不需要自己退场)。
11
+
12
+ ### 1. 粘贴的 API Key 进不了字段,只能手敲
13
+
14
+ 终端粘贴(Ctrl+V / Cmd+V / 右键,即括号粘贴)此前落到了向导 **Screen 背后**那个看不见的工作区草稿里:
15
+ 向导字段一个字都收不到,而粘贴并没丢——它会留在草稿里,Esc 离开向导后在输入行现形,按回车就会被当成
16
+ 一条消息发出去。受影响的是**所有做括号粘贴的终端**(Windows Terminal、iTerm2、GNOME Terminal、WezTerm、
17
+ kitty、VS Code 终端、tmux……),也就是几乎所有现代终端;受影响的是**每一个字段步骤**(提供商过滤、
18
+ Provider ID、Base URL、**API Key**、模型列表、上下文窗口)。
19
+
20
+ 现在粘贴归字段所有:字段步骤直接落进字段(行尾换行折叠掉,多行按分隔拼成一行),没有字段的步骤
21
+ (模型多选、确认)不接收,两种情况都不再改写工作区草稿。老控制台不发括号粘贴时,多行 burst 也按粘贴处理。
22
+
23
+ ### 2. 走完向导没有退回工作区
24
+
25
+ 保存成功后向导仍然占着屏,读者要随便按一个键才退得出去(那个键的"Screen 背后没有向导 → 关掉它"兜底
26
+ 才是真正的退场)。**保存本身就是退场**,现在与 Esc 一致:按 `y` 的同一帧,工作区回来。
27
+
28
+ 同一处的失败路径也修了:保存失败时错误写在了一个立刻被丢弃的状态上——Screen 留着、错误没了、填好的答案
29
+ 也没了,下一次按键还把这套流程当作没发生过。现在失败**保留向导与错误**,改完可以直接重试。
30
+
31
+ 向导自己的提示行会随 Screen 消失,所以"配置完成,已记住 X / Y"现在落在**转写行**里(凭据那一行是
32
+ footer 回显,会过期),回到工作区后还读得到。
33
+
34
+ ### 3. 一次读取里的多个按键(顺带修掉的同类)
35
+
36
+ 一次终端读取里的多个按键此前被当成一个键:`text\r`(慢链路上的快速输入、终端合并写入)会收下文字却
37
+ **丢掉那个回车**,步骤停住不动——读者只能再按一次回车。现在一次读取按 composer 一直以来的规则拆开:
38
+ 转义序列保持完整、CRLF 算一个回车、控制字节各自成键、可打印文本连成一段(所以没有括号粘贴的
39
+ "yes" 仍然不会替读者按下确认)。
40
+
41
+ ## 兼容
42
+
43
+ 上游兼容窗口与 0.8.2 相同:`0.2.0-rc.1` / `0.2.0-rc.2` / `0.1.7-rc.1` / `0.1.7-rc.2`。
44
+ **0.1.5 及更早的宿主不再支持。** 本版不改协议、不加命令、不动上游接口,也不改任何配置文件的形状。
45
+
46
+ ## 安装 / 升级
47
+
48
+ ```sh
49
+ dsh plugin --profile tui add dsh-ssh-tui@latest
50
+ ```
51
+
52
+ 装完退出再启动。`/status` 可确认当前版本;装不上先看 `/doctor`。
53
+ 更新时请显式带 `@latest`,否则已有 lockfile 可能继续沿用旧版本。
54
+
55
+ ## 实测数字(本版,100×20)
56
+
57
+ | 动作 | 毫秒(中位) | 字节 | 寻址行 | 全屏清屏 |
58
+ |---|---|---|---|---|
59
+ | 流式 delta | 1.67 | 501 | 3 | **0** |
60
+ | 等待卡时钟 | 1.12 | 465 | 3 | **0** |
61
+ | 报告屏翻页 | 1.21 | 2457 | 17 | **0** |
62
+ | 菜单光标移动 | 0.90 | 588 | 4 | **0** |
63
+ | 向导字段打字 | 0.19 | 140 | 1 | **0** |
64
+
65
+ 复现:`npm run bench`;README 里的同一张表由 `npm run bench:report` 从这次真跑生成。
66
+ 本版没有触碰绘制路径,数字与 0.8.2 同一量级。
67
+
68
+ ## 已知问题
69
+
70
+ - 向导字段没有左右光标键,粘贴总是追加在末尾(掩码字段 + 中间光标这条组合目前不存在)。
71
+ - `未测` 那一格在 Windows 本机看不到(ConPTY 代答光标位置),由管道父进程形状的探针覆盖。
72
+ - 完整已知问题与人工验收清单见 [`checkpoints.md`](checkpoints.md) 的 0.8.2 一节。
package/lib/tui.js CHANGED
@@ -451,6 +451,81 @@ const QUOTA_RETRY_MS = 15_000;
451
451
  const SETUP_STEPS = ['provider', 'id', 'base-url', 'key', 'models', 'models-pick', 'model-default', 'context', 'confirm'];
452
452
  /** The steps that show a text field (the others are pickers or the confirmation). */
453
453
  const SETUP_FIELD_STEPS = new Set(['provider', 'id', 'base-url', 'key', 'models', 'context']);
454
+ /**
455
+ * Whether one data chunk is a paste rather than keystrokes.
456
+ *
457
+ * Bracketed paste says so outright, and that is the path every modern terminal
458
+ * takes. A console without it sends the content as one burst instead, and a burst
459
+ * carrying more than one line break is not a human pressing Enter twice — they
460
+ * cannot press it twice inside one read. This is the fallback's one rule, kept
461
+ * here so every surface that can take a paste applies the same one.
462
+ */
463
+ function looksLikePasteBurst(text) {
464
+ let breaks = 0;
465
+ for (let index = 0; index < text.length; index += 1) {
466
+ const char = text[index];
467
+ if (char === '\r' && text[index + 1] !== '\n')
468
+ breaks += 1;
469
+ else if (char === '\n' && text[index - 1] !== '\r')
470
+ breaks += 1;
471
+ if (breaks > 1)
472
+ return true;
473
+ }
474
+ return false;
475
+ }
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;
528
+ }
454
529
  /**
455
530
  * The durable event types the plan projection reads (B2.5).
456
531
  *
@@ -11271,6 +11346,41 @@ export class SshTui {
11271
11346
  const screen = this.screen;
11272
11347
  if (screen === undefined)
11273
11348
  return;
11349
+ // Ctrl+L repaints the frame and belongs to the terminal rather than to any
11350
+ // surface that happens to be up: a Screen replaces the workspace entirely, so a
11351
+ // reader with a garbled terminal would otherwise have to leave the Screen to
11352
+ // clear it. The next paint is a full one, exactly as the composer's Ctrl+L is.
11353
+ if (combined === '\x0c') {
11354
+ this.screenNeedsFullPaint = true;
11355
+ this.markDirty();
11356
+ return;
11357
+ }
11358
+ // A burst of line breaks in one read is a paste from a console that cannot say so
11359
+ // (`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)) {
11364
+ this.pasteIntoScreen(combined);
11365
+ return;
11366
+ }
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
+ }
11274
11384
  // The setup Screen owns its own field, its own navigation and its own keys: it
11275
11385
  // consumes everything while it is up, and nothing reaches the workspace's
11276
11386
  // composer, history or transcript (B2.6 §4).
@@ -11683,14 +11793,23 @@ export class SshTui {
11683
11793
  * A paste is text input, so it goes through the same ownership question ordinary
11684
11794
  * typing does: into the composer's draft when the composer owns the keyboard, and
11685
11795
  * into a borrowed field when a surface that has one does. A surface without a
11686
- * text field (a confirmation, the inspect overlay) takes no paste at all — the
11796
+ * text field (a confirmation, a menu, a report) takes no paste at all — the
11687
11797
  * alternative was writing the composer's hidden draft behind it, which is how a
11688
11798
  * paste into an approval dialog could silently rewrite the message being written.
11799
+ *
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).
11689
11804
  */
11690
11805
  handlePasteText(text) {
11691
11806
  const normalized = text.replaceAll('\r\n', '\n').replaceAll('\r', '\n');
11692
11807
  if (normalized === '')
11693
11808
  return;
11809
+ if (this.screen !== undefined) {
11810
+ this.pasteIntoScreen(normalized);
11811
+ return;
11812
+ }
11694
11813
  if (!this.textIsEditable())
11695
11814
  return;
11696
11815
  this.leaveHistoryBrowse();
@@ -11705,29 +11824,52 @@ export class SshTui {
11705
11824
  }
11706
11825
  this.markDirty();
11707
11826
  }
11827
+ /**
11828
+ * Give a paste to the field the Screen owns, or to nobody.
11829
+ *
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.
11836
+ */
11837
+ pasteIntoScreen(text) {
11838
+ const state = this.onboarding;
11839
+ if (this.screen?.kind !== 'setup' || state === undefined || state.saving)
11840
+ return;
11841
+ if (!SETUP_FIELD_STEPS.has(state.step))
11842
+ 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 === '')
11850
+ return;
11851
+ this.setupInsert(state, flat);
11852
+ this.markDirty();
11853
+ }
11708
11854
  /**
11709
11855
  * Whether the keys that edit text belong to a field right now.
11710
11856
  *
11711
11857
  * The composer always has one; an open surface only has one when it borrowed it
11712
- * (a free-text answer, a setup field). Everything else — a confirmation, a menu,
11713
- * the inspect overlay — owns the keyboard without owning text, and those keys
11714
- * must do nothing rather than reach the draft behind the surface.
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.
11715
11863
  */
11716
11864
  textIsEditable() {
11865
+ if (this.screen !== undefined)
11866
+ return false;
11717
11867
  return this.dialog === undefined || this.borrowedText !== undefined;
11718
11868
  }
11719
11869
  handlePlainText(text) {
11720
11870
  // Fallback for terminals without bracketed paste: a burst of multiple line
11721
11871
  // breaks in one chunk is a paste, not repeated Enter presses.
11722
- let newlines = 0;
11723
- for (let index = 0; index < text.length; index += 1) {
11724
- const char = text[index];
11725
- if (char === '\r' && text[index + 1] !== '\n')
11726
- newlines += 1;
11727
- else if (char === '\n' && text[index - 1] !== '\r')
11728
- newlines += 1;
11729
- }
11730
- if (newlines > 1) {
11872
+ if (looksLikePasteBurst(text)) {
11731
11873
  this.handlePasteText(text);
11732
11874
  return;
11733
11875
  }
@@ -11747,11 +11889,15 @@ export class SshTui {
11747
11889
  }
11748
11890
  }
11749
11891
  handleChar(char) {
11750
- // A setup Screen owns every key, Enter included: it is the key that submits a
11751
- // step, and the composer behind the screen must never receive one (B2.6 §4).
11752
- // Ctrl+C is the exception — it is the terminal's own interrupt and keeps its
11753
- // global meaning.
11754
- if (this.screen?.kind === 'setup' && char !== '\x03') {
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) {
11755
11901
  this.handleScreenInput(char, this.keymap.sequences.get(char));
11756
11902
  return;
11757
11903
  }
@@ -12249,13 +12395,6 @@ export class SshTui {
12249
12395
  this.markDirty();
12250
12396
  return;
12251
12397
  }
12252
- if (combined === '\x0c') {
12253
- // Ctrl+L still repaints the whole frame: the field is a row like any other,
12254
- // and a forced repaint is how a reader clears a garbled terminal.
12255
- this.screenNeedsFullPaint = true;
12256
- this.markDirty();
12257
- return;
12258
- }
12259
12398
  if (combined === '\x15') {
12260
12399
  // Ctrl+U clears the wizard's field, not the workspace's draft.
12261
12400
  this.setupSetField(state, '', 0);
@@ -12725,7 +12864,7 @@ export class SshTui {
12725
12864
  this.setupMessage(state, 'system', t('onboard.baseSaved', { path: displayDshPath('settings.yaml') }));
12726
12865
  }
12727
12866
  if (saved) {
12728
- this.setupMessage(state, 'system', t('onboard.officialDone', { model }));
12867
+ this.onboardDone(t('onboard.officialDone', { model }));
12729
12868
  }
12730
12869
  }
12731
12870
  else {
@@ -12898,7 +13037,7 @@ export class SshTui {
12898
13037
  this.onSelectionChanged?.(selection);
12899
13038
  await this.rememberRoute(selection);
12900
13039
  await this.syncSubagentToProvider(ownerId, state.models, previousParentProvider !== ownerId);
12901
- this.setupMessage(state, 'system', t('onboard.customDone', { id: ownerId, model }));
13040
+ this.onboardDone(t('onboard.customDone', { id: ownerId, model }));
12902
13041
  }
12903
13042
  }
12904
13043
  }
@@ -12907,12 +13046,45 @@ export class SshTui {
12907
13046
  this.setupMessage(state, 'error', t('onboard.saveFailed', { error: errorChain(error) }));
12908
13047
  }
12909
13048
  finally {
12910
- this.onboarding = undefined;
12911
13049
  state.resolve(saved);
13050
+ // The Screen's exit is the answer, and the answer is one of two things.
13051
+ //
13052
+ // **Saved**: the wizard has nothing left to ask, so it hands the workspace back
13053
+ // in the same frame. Cancelling always did this; saving did not, so a completed
13054
+ // setup left the reader on the wizard's own frame until they pressed a key —
13055
+ // and only that key's "no wizard behind this Screen" fallback closed it. Every
13056
+ // confirmation the wizard would have painted on its own row went with the
13057
+ // Screen, which is why the completion line is a transcript row (`onboardDone`).
13058
+ //
13059
+ // **Failed**: the wizard keeps the screen, because the row it just wrote is the
13060
+ // reason and the answer (an API key, a Base URL) has to stay editable to retry.
13061
+ // Dropping the state here is what used to leave a Screen with no wizard behind
13062
+ // it: the error was written and then thrown away with it, and the next keypress
13063
+ // dismissed the whole flow as if nothing had been asked.
13064
+ if (saved) {
13065
+ this.onboarding = undefined;
13066
+ this.closeScreen();
13067
+ }
13068
+ else {
13069
+ state.saving = false;
13070
+ }
12912
13071
  this.showNextDialog();
12913
13072
  this.markDirty();
12914
13073
  }
12915
13074
  }
13075
+ /**
13076
+ * Say the wizard is done, where the reader will still be able to read it.
13077
+ *
13078
+ * A saved wizard closes its Screen in the same frame, so its own notice row would
13079
+ * be painted for no frames at all — and the credential line that goes with it is a
13080
+ * footer echo (`command-feedback`), which expires. This is the durable half of the
13081
+ * answer: `command-status` routes to the transcript, exactly like the boot line
13082
+ * that asked for the setup in the first place.
13083
+ */
13084
+ onboardDone(text) {
13085
+ this.pushRow(represent('command-status', { kind: 'system', text }));
13086
+ this.markDirty();
13087
+ }
12916
13088
  /** Store one credential, falling back to a launch-environment override on shadow/absence. */
12917
13089
  async saveCredential(credentials, envRef, key) {
12918
13090
  const shadowing = process.env[envRef];