@sayknow-cli/coding-agent 0.6.6 → 0.6.8

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.
Files changed (65) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/dist/types/config/settings-schema.d.ts +79 -0
  3. package/dist/types/i18n/messages/en.d.ts +2 -0
  4. package/dist/types/modes/components/welcome.d.ts +21 -4
  5. package/dist/types/modes/interactive-mode.d.ts +2 -5
  6. package/dist/types/modes/types.d.ts +2 -0
  7. package/dist/types/sdk/broker/broker.d.ts +22 -0
  8. package/dist/types/sdk/broker/process-guard.d.ts +71 -0
  9. package/dist/types/sdk/broker/transport.d.ts +2 -0
  10. package/dist/types/sdk/bus/chat-daemon-runtime.d.ts +4 -0
  11. package/dist/types/session/agent-session.d.ts +9 -0
  12. package/dist/types/session/auth-storage-discovery.d.ts +15 -0
  13. package/dist/types/session/auto-fallback.d.ts +27 -0
  14. package/dist/types/session/fallback-chain-controller.d.ts +5 -0
  15. package/dist/types/session/response-language.d.ts +25 -0
  16. package/dist/types/setup/model-onboarding-guidance.d.ts +8 -1
  17. package/dist/types/setup/provider-onboarding.d.ts +2 -0
  18. package/dist/types/tools/index.d.ts +1 -0
  19. package/dist/types/tools/locate-core.d.ts +97 -0
  20. package/dist/types/tools/locate.d.ts +44 -0
  21. package/package.json +7 -7
  22. package/scripts/generate-sdk-operation-inventory.ts +4 -0
  23. package/src/cli/setup-cli.ts +7 -4
  24. package/src/commands/sdk.ts +3 -0
  25. package/src/commands/setup.ts +4 -1
  26. package/src/config/settings-schema.ts +82 -0
  27. package/src/decisions/typesafe-backend.ts +38 -4
  28. package/src/i18n/messages/de.settings.ts +26 -0
  29. package/src/i18n/messages/de.ts +2 -0
  30. package/src/i18n/messages/en.ts +2 -0
  31. package/src/i18n/messages/es.settings.ts +26 -0
  32. package/src/i18n/messages/es.ts +2 -0
  33. package/src/i18n/messages/fr.settings.ts +26 -0
  34. package/src/i18n/messages/fr.ts +2 -0
  35. package/src/i18n/messages/ja.settings.ts +26 -0
  36. package/src/i18n/messages/ja.ts +2 -0
  37. package/src/i18n/messages/ko.settings.ts +25 -0
  38. package/src/i18n/messages/ko.ts +2 -0
  39. package/src/i18n/messages/zh.settings.ts +23 -0
  40. package/src/i18n/messages/zh.ts +2 -0
  41. package/src/internal-urls/docs-index.generated.ts +7 -6
  42. package/src/modes/components/welcome.ts +241 -68
  43. package/src/modes/controllers/input-controller.ts +2 -0
  44. package/src/modes/controllers/selector-controller.ts +13 -0
  45. package/src/modes/interactive-mode.ts +123 -14
  46. package/src/modes/types.ts +2 -0
  47. package/src/prompts/system/system-prompt.md +7 -2
  48. package/src/prompts/tools/locate.md +12 -0
  49. package/src/sdk/broker/broker.ts +75 -1
  50. package/src/sdk/broker/process-guard.ts +160 -0
  51. package/src/sdk/broker/transport.ts +15 -1
  52. package/src/sdk/bus/chat-daemon-runtime.ts +13 -1
  53. package/src/sdk/protocol/operation-inventory.generated.json +22 -0
  54. package/src/sdk/session.ts +4 -1
  55. package/src/session/agent-session.ts +118 -2
  56. package/src/session/auth-storage-discovery.ts +22 -7
  57. package/src/session/auto-fallback.ts +59 -0
  58. package/src/session/fallback-chain-controller.ts +5 -0
  59. package/src/session/response-language.ts +71 -0
  60. package/src/setup/model-onboarding-guidance.ts +29 -14
  61. package/src/setup/provider-onboarding.ts +5 -0
  62. package/src/slash-commands/builtin-registry.ts +112 -2
  63. package/src/tools/index.ts +3 -0
  64. package/src/tools/locate-core.ts +720 -0
  65. package/src/tools/locate.ts +203 -0
@@ -16,6 +16,8 @@ import { theme } from "../../modes/theme/theme";
16
16
  export interface RecentSession {
17
17
  name: string;
18
18
  timeAgo: string;
19
+ /** Session file; present when the row can be opened from the card. */
20
+ path?: string;
19
21
  }
20
22
 
21
23
  /**
@@ -49,21 +51,34 @@ export interface WelcomeComponentOptions {
49
51
  continueKey?: string;
50
52
  /** Which Sayknow pet stands on the card (default red). */
51
53
  petSkin?: PetSkinId;
54
+ /** Called when a session row is opened by click or Enter. */
55
+ onOpenSession?: (session: RecentSession) => void;
52
56
  }
53
57
 
54
58
  /** Left margin of the card, and the widest the card grows on wide terminals. */
55
59
  const MARGIN = 2;
56
60
  const MAX_CARD_WIDTH = 76;
57
- /** Gap between the mark and the identity lines. */
61
+ /** Gap between the pet and the wordmark. */
58
62
  const MARK_GAP = 3;
59
- /** Below this card width the mark is dropped and only the identity lines remain. */
60
- const MIN_WIDTH_FOR_MARK = 44;
61
63
  /** Recent sessions on the card; the resume picker has the rest. */
62
64
  const SESSION_ROWS = 3;
63
65
 
66
+ /**
67
+ * The wordmark: SAYKNOW in the brand's box-drawing letters, then CLI. Each row is the
68
+ * SAYKNOW part and the CLI part, drawn in different colors.
69
+ */
70
+ // biome-ignore format: preserve letter layout
71
+ const WORDMARK: ReadonlyArray<readonly [string, string]> = [
72
+ ["╔═╗╔═╗╦ ╦╦╔═╔╗╔╔═╗╦ ╦", "╔═╗╦ ╦"],
73
+ ["╚═╗╠═╣╚╦╝╠╩╗║║║║ ║║║║", "║ ║ ║"],
74
+ ["╚═╝╩ ╩ ╩ ╩ ╩╝╚╝╚═╝╚╩╝", "╚═╝╩═╝╩"],
75
+ ];
76
+ const WORDMARK_GAP = 2;
77
+ const WORDMARK_WIDTH = 21 + WORDMARK_GAP + 7;
78
+
64
79
  /** ASCII-safe stand-in for the pet when the banner must avoid block glyphs. */
65
- const MARK_ASCII = [" .---. ", " ( o o ) ", " ) ( ", " /\\/\\/\\/ "] as const;
66
- const MARK_ASCII_WAVE = " \\/\\/\\/\\ ";
80
+ const MARK_ASCII = [" .--. ", "( oo )", " ) ( ", "/\\/\\/\\"] as const;
81
+ const MARK_ASCII_WAVE = "\\/\\/\\/";
67
82
 
68
83
  /**
69
84
  * Intro: the pet does a short para-para (tentacles sway left, right, left) and
@@ -80,18 +95,25 @@ interface Section {
80
95
  lines: string[];
81
96
  /** When the viewport is short, the section with the highest rank is dropped first. */
82
97
  dropRank: number;
98
+ /** Session index per line, for rows that open a session. */
99
+ sessionRows?: Array<number | undefined>;
83
100
  }
84
101
 
85
102
  /**
86
- * Sayknow-CLI launch card: the octopus mark beside who and where you are
87
- * (version, model and reasoning, project and branch), then the three most recent
88
- * sessions and a single row of keys. Everything else — the full session list,
89
- * workflows, the keymap, release notes — is one key away, not on the card.
103
+ * Sayknow-CLI launch card: the wordmark with the pet beside it, who and where you are
104
+ * (version, model and reasoning, project and branch), the release line after an update,
105
+ * the three most recent sessions — which can be opened from the card with a click or
106
+ * ↓ then Enter — and a single row of keys.
90
107
  */
91
108
  export class WelcomeComponent implements Component {
92
109
  #animStart: number | null = null;
93
110
  #animTimer: NodeJS.Timeout | null = null;
94
111
  #snapshot: WelcomeSnapshot;
112
+ /** Highlighted session row while picking with the keyboard; undefined when not picking. */
113
+ #selected: number | undefined;
114
+ /** Session index per rendered line, from the last frame. */
115
+ #lineSessions: Array<number | undefined> = [];
116
+ #interactive = true;
95
117
 
96
118
  constructor(
97
119
  private readonly version: string,
@@ -145,6 +167,7 @@ export class WelcomeComponent implements Component {
145
167
 
146
168
  setRecentSessions(sessions: RecentSession[]): void {
147
169
  this.recentSessions = sessions;
170
+ if (this.#selected !== undefined && this.#selected >= this.#openable().length) this.#selected = undefined;
148
171
  }
149
172
 
150
173
  /** Merge newly probed workspace facts into the card. */
@@ -152,7 +175,78 @@ export class WelcomeComponent implements Component {
152
175
  this.#snapshot = { ...this.#snapshot, ...patch };
153
176
  }
154
177
 
178
+ // ── Picking a session ───────────────────────────────────────────────────
179
+
180
+ /** Sessions on the card that can be opened (they have a file). */
181
+ #openable(): RecentSession[] {
182
+ return this.recentSessions.slice(0, SESSION_ROWS).filter(session => session.path);
183
+ }
184
+
185
+ /** Rows that can be opened right now. Zero once the card stops taking input. */
186
+ get openableCount(): number {
187
+ return this.#interactive ? this.#openable().length : 0;
188
+ }
189
+
190
+ get selectedIndex(): number | undefined {
191
+ return this.#selected;
192
+ }
193
+
194
+ /** Stop offering the session rows (after the first prompt or a session switch). */
195
+ endInteraction(): void {
196
+ this.#interactive = false;
197
+ this.#selected = undefined;
198
+ }
199
+
200
+ get interactive(): boolean {
201
+ return this.#interactive;
202
+ }
203
+
204
+ /** Highlight a row, or clear the highlight with undefined. Returns false when there is nothing to select. */
205
+ select(index: number | undefined): boolean {
206
+ if (index === undefined) {
207
+ this.#selected = undefined;
208
+ return true;
209
+ }
210
+ const count = this.openableCount;
211
+ if (count === 0) return false;
212
+ this.#selected = Math.max(0, Math.min(count - 1, index));
213
+ return true;
214
+ }
215
+
216
+ /** Move the highlight; returns false when it would leave the list upward (the caller hands focus back). */
217
+ moveSelection(delta: number): boolean {
218
+ if (this.#selected === undefined) return this.select(0);
219
+ const next = this.#selected + delta;
220
+ if (next < 0) {
221
+ this.#selected = undefined;
222
+ return false;
223
+ }
224
+ this.#selected = Math.min(this.openableCount - 1, next);
225
+ return true;
226
+ }
227
+
228
+ /** Open the highlighted row. */
229
+ openSelected(): boolean {
230
+ const session = this.#selected === undefined ? undefined : this.#openable()[this.#selected];
231
+ if (!session) return false;
232
+ this.options.onOpenSession?.(session);
233
+ return true;
234
+ }
235
+
236
+ handleClick(line: number): boolean {
237
+ if (!this.#interactive) return false;
238
+ const index = this.#lineSessions[line];
239
+ const session = index === undefined ? undefined : this.#openable()[index];
240
+ if (!session) return false;
241
+ this.#selected = index;
242
+ this.options.onOpenSession?.(session);
243
+ return true;
244
+ }
245
+
246
+ // ── Layout ──────────────────────────────────────────────────────────────
247
+
155
248
  render(termWidth: number): string[] {
249
+ this.#lineSessions = [];
156
250
  const gutterWidth = this.#rightGutterWidth(termWidth);
157
251
  const width = Math.max(0, termWidth - gutterWidth);
158
252
  if (width < 4) return [];
@@ -163,76 +257,113 @@ export class WelcomeComponent implements Component {
163
257
 
164
258
  const cardWidth = Math.max(1, Math.min(MAX_CARD_WIDTH, width - MARGIN));
165
259
  const sections = this.#sections(cardWidth);
166
- const lines = this.#fitRows(sections, targetRows);
260
+ const { lines, sessionRows } = this.#fitRows(sections, targetRows);
167
261
  const margin = padding(MARGIN);
168
- const out = lines.map(line => this.#fit(line ? margin + line : "", width));
262
+ const selectedLine = sessionRows.findIndex(index => index !== undefined && index === this.#selected);
263
+ const out = lines.map((line, row) => {
264
+ if (row === selectedLine) return this.#highlightRow(line, cardWidth, width);
265
+ return this.#fit(line ? margin + line : "", width);
266
+ });
169
267
  if (targetRows !== undefined) while (out.length < targetRows) out.push(padding(width));
268
+ this.#lineSessions = sessionRows;
170
269
  return this.#withRightGutter(out, gutterWidth);
171
270
  }
172
271
 
272
+ /** The picked row: a selection band across the card width, held after every reset inside the row. */
273
+ #highlightRow(line: string, cardWidth: number, width: number): string {
274
+ const bg = theme.getBgAnsi("selectedBg");
275
+ const body = this.#fit(line, cardWidth).replace(/\x1b\[(?:0)?m|\x1b\[49m/g, match => `${match}${bg}`);
276
+ return this.#fit(`${padding(MARGIN)}${bg}${body}\x1b[0m`, width);
277
+ }
278
+
173
279
  /**
174
280
  * Lay the sections out with a blank row above and between them. When the
175
281
  * viewport is short, whole sections go by rank — keys, then the release
176
282
  * note, then sessions — and the identity block is clipped last.
177
283
  */
178
- #fitRows(sections: { identity: string[]; others: Section[] }, targetRows: number | undefined): string[] {
284
+ #fitRows(
285
+ sections: { identity: string[]; others: Section[] },
286
+ targetRows: number | undefined,
287
+ ): { lines: string[]; sessionRows: Array<number | undefined> } {
179
288
  const colored = this.#colorReveal(1 + sections.others.length);
180
289
  const identity = this.#inkSection(sections.identity, 0, colored);
181
290
  let others = sections.others.map((section, index) => ({
182
291
  ...section,
183
292
  lines: this.#inkSection(section.lines, index + 1, colored),
184
293
  }));
185
- const assemble = (): string[] => {
186
- const out = ["", ...identity];
187
- for (const section of others) out.push("", ...section.lines);
188
- return out;
294
+ const assemble = (): { lines: string[]; sessionRows: Array<number | undefined> } => {
295
+ const lines = ["", ...identity];
296
+ const sessionRows: Array<number | undefined> = lines.map(() => undefined);
297
+ for (const section of others) {
298
+ lines.push("", ...section.lines);
299
+ sessionRows.push(undefined, ...(section.sessionRows ?? section.lines.map(() => undefined)));
300
+ }
301
+ return { lines, sessionRows };
189
302
  };
190
- let lines = assemble();
191
- if (targetRows === undefined) return lines;
192
- while (lines.length > targetRows && others.length > 0) {
303
+ let result = assemble();
304
+ if (targetRows === undefined) return result;
305
+ while (result.lines.length > targetRows && others.length > 0) {
193
306
  const drop = others.reduce((worst, section) => (section.dropRank > worst.dropRank ? section : worst));
194
307
  others = others.filter(section => section !== drop);
195
- lines = assemble();
308
+ result = assemble();
196
309
  }
197
- return lines.slice(0, targetRows);
310
+ return { lines: result.lines.slice(0, targetRows), sessionRows: result.sessionRows.slice(0, targetRows) };
198
311
  }
199
312
 
200
313
  #sections(cardWidth: number): { identity: string[]; others: Section[] } {
201
314
  const others: Section[] = [];
202
315
  const note = this.#releaseNoteLine(cardWidth);
203
316
  if (note) others.push({ lines: [note], dropRank: 2 });
204
- others.push({ lines: this.#sessionLines(cardWidth), dropRank: 1 });
317
+ const sessions = this.#sessionSection(cardWidth);
318
+ others.push({ ...sessions, dropRank: 1 });
205
319
  others.push({ lines: [this.#keysLine(cardWidth)], dropRank: 3 });
206
320
  return { identity: this.#identityLines(cardWidth), others };
207
321
  }
208
322
 
209
- // ── Identity: mark + who and where ──────────────────────────────────────
323
+ // ── Identity: pet + wordmark, then who and where ────────────────────────
210
324
 
211
325
  #identityLines(cardWidth: number): string[] {
212
- const withMark = cardWidth >= MIN_WIDTH_FOR_MARK;
213
- const mark = withMark ? this.#markRows() : [];
326
+ const mark = this.#markRows();
214
327
  const markWidth = Math.max(0, ...mark.map(row => visibleWidth(row)));
215
- const infoWidth = Math.max(1, withMark ? cardWidth - markWidth - MARK_GAP : cardWidth);
216
- const info = [
217
- this.#truncate(this.#titleLine(!withMark), infoWidth),
218
- this.#truncate(theme.fg("muted", t("welcome.tagline")), infoWidth),
219
- this.#truncate(this.#modelLine(), infoWidth),
220
- this.#truncate(this.#whereLine(infoWidth), infoWidth),
221
- ];
222
- if (!withMark) return info;
223
- // Center the four identity lines against the pet's height.
224
- const offset = Math.max(0, Math.floor((mark.length - info.length) / 2));
225
- const rows = Math.max(mark.length, info.length + offset);
226
- const out: string[] = [];
227
- for (let row = 0; row < rows; row++) {
228
- const art = mark[row] ?? padding(markWidth);
229
- const text = info[row - offset] ?? "";
230
- out.push(text ? `${art}${padding(MARK_GAP)}${text}` : art);
231
- }
232
- return out;
328
+ const withMark = cardWidth >= markWidth + MARK_GAP + WORDMARK_WIDTH;
329
+ const withWordmark = this.logoMode !== "ascii" && cardWidth >= WORDMARK_WIDTH;
330
+ const indent = withMark ? markWidth + MARK_GAP : 0;
331
+ const infoWidth = Math.max(1, cardWidth - indent);
332
+
333
+ const head: string[] = withWordmark
334
+ ? WORDMARK.map(
335
+ ([word, suffix]) =>
336
+ `${theme.bold(theme.fg("accent", word))}${padding(WORDMARK_GAP)}${theme.fg("text", suffix)}`,
337
+ )
338
+ : [this.#truncate(this.#titleLine(!withMark), infoWidth)];
339
+ const versionLine = this.#spread(
340
+ theme.fg("muted", t("welcome.tagline")),
341
+ theme.fg("dim", this.#versionLabel()),
342
+ infoWidth,
343
+ );
344
+ const info = [...head, withWordmark ? versionLine : theme.fg("muted", t("welcome.tagline"))];
345
+
346
+ // Model and place sit under the wordmark, in the same column — starting beside the
347
+ // pet's last rows when the pet is taller than the wordmark, so no blank row opens.
348
+ const text = [...info, ...this.#statusLines(infoWidth)];
349
+ const rows: string[] = [];
350
+ if (withMark) {
351
+ const count = Math.max(mark.length, text.length);
352
+ for (let row = 0; row < count; row++) {
353
+ const art = mark[row] ?? padding(markWidth);
354
+ const line = text[row] ?? "";
355
+ rows.push(line ? `${art}${padding(MARK_GAP)}${line}` : art);
356
+ }
357
+ } else rows.push(...text);
358
+ return rows;
359
+ }
360
+
361
+ #versionLabel(): string {
362
+ const buildLabel = this.options.buildLabel ?? formatBuildLabel();
363
+ return `v${this.version} · ${buildLabel}`;
233
364
  }
234
365
 
235
- /** The Sayknow pet itself, in half blocks; the pose follows the intro. */
366
+ /** The Sayknow pet (its own sprite at three quarters size), in half blocks; the pose follows the intro. */
236
367
  #markRows(): string[] {
237
368
  const step = this.#animStart == null ? -1 : Math.floor((performance.now() - this.#animStart) / WAVE_STEP_MS);
238
369
  if (this.logoMode === "ascii") {
@@ -241,17 +372,30 @@ export class WelcomeComponent implements Component {
241
372
  return rows.map(row => theme.fg("accent", row));
242
373
  }
243
374
  const pose = step >= 0 && step < INTRO_POSES.length ? INTRO_POSES[step]! : "base";
244
- return renderPetHalfBlocks(pose, this.options.petSkin ?? "red", theme.getColorMode());
375
+ return renderPetHalfBlocks(pose, this.options.petSkin ?? "red", theme.getColorMode(), { scale: "compact" });
245
376
  }
246
377
 
247
- /** `withIcon` adds the 🐙 glyph for layouts that have no room for the drawn mark. */
378
+ /** One-line title for layouts without room for the wordmark. */
248
379
  #titleLine(withIcon = false): string {
249
- const buildLabel = this.options.buildLabel ?? formatBuildLabel();
250
380
  const mark = withIcon && this.logoMode !== "ascii" && theme.icon.pi ? `${theme.icon.pi} ` : "";
251
- return `${mark}${theme.bold(theme.fg("text", "Sayknow-CLI"))}${theme.fg("dim", ` v${this.version} · ${buildLabel}`)}`;
381
+ return `${mark}${theme.bold(theme.fg("accent", "Sayknow-CLI"))}${theme.fg("dim", ` ${this.#versionLabel()}`)}`;
382
+ }
383
+
384
+ /**
385
+ * Model and reasoning, then project path and branch: on one row when both fit whole,
386
+ * otherwise the place gets its own row rather than being cut to nothing.
387
+ */
388
+ #statusLines(width: number): string[] {
389
+ const model = this.#truncate(this.#modelPart(), width);
390
+ const sep = theme.fg("dim", " · ");
391
+ const fullWhere = this.#wherePart(Number.POSITIVE_INFINITY);
392
+ if (!fullWhere) return [model];
393
+ if (visibleWidth(model) + visibleWidth(sep) + visibleWidth(fullWhere) <= width)
394
+ return [`${model}${sep}${fullWhere}`];
395
+ return [model, this.#wherePart(width)];
252
396
  }
253
397
 
254
- #modelLine(): string {
398
+ #modelPart(): string {
255
399
  const sep = theme.fg("dim", " · ");
256
400
  if (this.modelName === "Unknown" || this.modelName.length === 0) {
257
401
  return `${theme.fg("accent", t("welcome.chooseModel"))}${sep}${theme.fg("dim", t("welcome.modelHint"))}`;
@@ -263,7 +407,7 @@ export class WelcomeComponent implements Component {
263
407
  return parts.join(sep);
264
408
  }
265
409
 
266
- #whereLine(width: number): string {
410
+ #wherePart(width: number): string {
267
411
  const { cwd, branch } = this.#snapshot;
268
412
  const sep = theme.fg("dim", " · ");
269
413
  const branchPart =
@@ -293,7 +437,19 @@ export class WelcomeComponent implements Component {
293
437
 
294
438
  // ── Release note, sessions, keys ────────────────────────────────────────
295
439
 
296
- /** One line, only after an update: the new version, its first note, and a pointer to /changelog. */
440
+ /**
441
+ * A section header that is also the divider: the label, a hairline rule filling the
442
+ * row, and hints at the right end.
443
+ */
444
+ #ruleHeader(label: string, hint: string, width: number): string {
445
+ const left = `${label} `;
446
+ const right = hint ? ` ${hint}` : "";
447
+ const fill = width - visibleWidth(left) - visibleWidth(right);
448
+ if (fill < 3) return this.#truncate(`${label}${right}`, width);
449
+ return `${left}${theme.fg("borderMuted", "─".repeat(fill))}${right}`;
450
+ }
451
+
452
+ /** One line, only after an update: the new version and its first note, pointing at /changelog. */
297
453
  #releaseNoteLine(width: number): string | undefined {
298
454
  const changelog = this.options.changelogMarkdown?.trim();
299
455
  if (!changelog) return undefined;
@@ -301,29 +457,46 @@ export class WelcomeComponent implements Component {
301
457
  const lead = theme.bold(theme.fg("accent", `v${version}`));
302
458
  const pointer = theme.fg("dim", "/changelog");
303
459
  const first = this.options.collapseChangelog ? undefined : this.#changelogItems(changelog)[0];
304
- if (!first) return this.#spread(lead, pointer, width);
305
- const room = width - visibleWidth(lead) - visibleWidth(pointer) - 4;
306
- const body = room >= 12 ? ` ${theme.fg("muted", this.#truncate(first, room))}` : "";
307
- return this.#spread(`${lead}${body}`, pointer, width);
460
+ const room = width - visibleWidth(lead) - visibleWidth(pointer) - 8;
461
+ const label = first && room >= 12 ? `${lead} ${theme.fg("muted", this.#truncate(first, room))}` : lead;
462
+ return this.#ruleHeader(label, pointer, width);
308
463
  }
309
464
 
310
- #sessionLines(width: number): string[] {
465
+ #sessionSection(width: number): Pick<Section, "lines" | "sessionRows"> {
311
466
  const heading = theme.bold(theme.fg("accent", t("welcome.sessionTrail")));
312
- if (this.recentSessions.length === 0) return [heading, theme.fg("dim", t("welcome.noSessions"))];
467
+ if (this.recentSessions.length === 0) {
468
+ return { lines: [this.#ruleHeader(heading, "", width), ` ${theme.fg("dim", t("welcome.noSessions"))}`] };
469
+ }
313
470
  const context = this.options.keyDisplayContext ?? { platform: process.platform };
314
- const hints = [t("welcome.allSessions", { key: this.#resumeKey() })];
315
- if (this.options.continueKey)
316
- hints.unshift(t("welcome.continue", { key: formatKeyHint(this.options.continueKey, context) }));
317
- const lines = [this.#spread(heading, theme.fg("dim", hints.join(" · ")), width)];
471
+ const picking = this.#selected !== undefined;
472
+ const hints = picking
473
+ ? [t("welcome.pickActive")]
474
+ : [
475
+ ...(this.openableCount > 0 ? [t("welcome.pick", { key: "↓" })] : []),
476
+ ...(this.options.continueKey
477
+ ? [t("welcome.continue", { key: formatKeyHint(this.options.continueKey, context) })]
478
+ : []),
479
+ t("welcome.allSessions", { key: this.#resumeKey() }),
480
+ ];
481
+ const lines = [this.#ruleHeader(heading, theme.fg("dim", hints.join(" · ")), width)];
482
+ const sessionRows: Array<number | undefined> = [undefined];
483
+ const openable = this.#openable();
318
484
  this.recentSessions.slice(0, SESSION_ROWS).forEach((session, index) => {
319
- // The first row is the one the continue key resumes; mark it.
320
- const lead = index === 0 && this.options.continueKey ? theme.fg("accent", "› ") : " ";
485
+ const openIndex = openable.indexOf(session);
486
+ const selected = picking && openIndex === this.#selected;
487
+ // Idle: the first row is the one the continue key resumes. Picking: the highlighted row.
488
+ const marked = picking ? selected : index === 0 && !!this.options.continueKey;
489
+ const lead = marked ? theme.fg("accent", "› ") : " ";
321
490
  const time = theme.fg("dim", session.timeAgo);
322
491
  const nameWidth = Math.max(1, width - visibleWidth(lead) - visibleWidth(time) - 2);
323
- const name = theme.fg(index === 0 ? "text" : "muted", this.#truncate(session.name, nameWidth));
324
- lines.push(this.#spread(`${lead}${name}`, time, width));
492
+ const tone = selected ? "text" : picking ? "muted" : index === 0 ? "text" : "muted";
493
+ const name = this.#truncate(session.name, nameWidth);
494
+ lines.push(
495
+ this.#spread(`${lead}${selected ? theme.bold(theme.fg(tone, name)) : theme.fg(tone, name)}`, time, width),
496
+ );
497
+ sessionRows.push(openIndex >= 0 ? openIndex : undefined);
325
498
  });
326
- return lines;
499
+ return { lines, sessionRows };
327
500
  }
328
501
 
329
502
  #keysLine(width: number): string {
@@ -335,8 +508,8 @@ export class WelcomeComponent implements Component {
335
508
  { key: "?", label: "welcome.keymap" },
336
509
  ];
337
510
  const text = items
338
- .map(item => `${theme.fg("text", item.key)} ${theme.fg("dim", t(item.label))}`)
339
- .join(theme.fg("dim", " "));
511
+ .map(item => `${theme.fg("accent", item.key)} ${theme.fg("dim", t(item.label))}`)
512
+ .join(theme.fg("borderMuted", " · "));
340
513
  return this.#truncate(text, width);
341
514
  }
342
515
 
@@ -848,6 +848,8 @@ export class InputController {
848
848
 
849
849
  async submitText(text: string, composer: ComposerSubmissionOptions): Promise<void> {
850
850
  text = text.trim();
851
+ // Past the launch card: give the mouse back to the terminal and stop offering its rows.
852
+ if (text) this.ctx.endWelcomeInteraction?.();
851
853
  if ((!isSettingsInitialized() || settings.get("emojiAutocomplete")) && text) text = expandEmoticons(text);
852
854
  if (this.ctx.hasActiveBtw()) {
853
855
  if (!text) return;
@@ -70,6 +70,7 @@ import {
70
70
  } from "../../sdk/bus/telegram-daemon";
71
71
  import { TelegramDaemonController } from "../../sdk/bus/telegram-daemon-control";
72
72
  import { runTelegramSetup, type TelegramSetupPreflight } from "../../sdk/bus/telegram-setup";
73
+ import { applyCredentialRankingModeSetting } from "../../session/auth-storage-discovery";
73
74
  import { type SessionInfo, SessionManager } from "../../session/session-manager";
74
75
  import { getTreeForInternalRead } from "../../session/session-manager-internal";
75
76
 
@@ -1372,6 +1373,18 @@ export class SelectorController {
1372
1373
  });
1373
1374
  break;
1374
1375
  }
1376
+ case "auth.credentialRankingMode": {
1377
+ const effective = applyCredentialRankingModeSetting(
1378
+ this.ctx.session.modelRegistry.authStorage,
1379
+ this.ctx.settings,
1380
+ );
1381
+ if (effective !== value) {
1382
+ this.ctx.showWarning(
1383
+ `SKC_CREDENTIAL_RANKING_MODE=${effective} is set, so it overrides this setting on this machine.`,
1384
+ );
1385
+ }
1386
+ break;
1387
+ }
1375
1388
  case "clearOnShrink":
1376
1389
  this.ctx.ui.setClearOnShrink(value as boolean);
1377
1390
  break;