@oh-my-pi/pi-tui 18.4.0 → 18.4.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.
Files changed (65) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/dist/types/chat/image-loading.d.ts +24 -0
  3. package/dist/types/chat/transcript-entry.d.ts +9 -0
  4. package/dist/types/chrome/transcript-container.d.ts +24 -3
  5. package/dist/types/overlays/agents-hub.d.ts +7 -4
  6. package/dist/types/overlays/model-browser.d.ts +15 -0
  7. package/dist/types/overlays/rewind-selector.d.ts +4 -3
  8. package/dist/types/overlays/usage-dashboard.d.ts +9 -2
  9. package/dist/types/prompt/composer-attachments.d.ts +7 -0
  10. package/dist/types/prompt/composer-cache.d.ts +34 -16
  11. package/dist/types/prompt/composer.d.ts +15 -18
  12. package/dist/types/prompt/model-mention-autocomplete.d.ts +4 -1
  13. package/dist/types/prompt/welcome.d.ts +5 -2
  14. package/dist/types/render/width-aware-text.d.ts +6 -0
  15. package/dist/types/status-line/component.d.ts +13 -4
  16. package/dist/types/status-line/metrics.d.ts +0 -1
  17. package/dist/types/status-line/startup.d.ts +38 -0
  18. package/dist/types/status-line/types.d.ts +0 -2
  19. package/dist/types/terminal-capabilities.d.ts +10 -6
  20. package/dist/types/terminal.d.ts +7 -0
  21. package/dist/types/theme/color.d.ts +1 -0
  22. package/dist/types/theme/session-color.d.ts +3 -3
  23. package/dist/types/theme/shimmer.d.ts +1 -1
  24. package/dist/types/theme/theme-class.d.ts +1 -1
  25. package/dist/types/tools/output-meta.d.ts +2 -1
  26. package/dist/types/tools/vibe.d.ts +4 -0
  27. package/dist/types/tui.d.ts +3 -2
  28. package/package.json +9 -9
  29. package/src/chat/assistant-message.ts +27 -21
  30. package/src/chat/image-loading.ts +81 -0
  31. package/src/chat/tool-execution.ts +53 -17
  32. package/src/chat/transcript-entry.ts +16 -0
  33. package/src/chrome/transcript-container.ts +282 -86
  34. package/src/components/editor.ts +28 -6
  35. package/src/components/image.ts +25 -9
  36. package/src/components/loader.ts +37 -17
  37. package/src/overlays/agent-hub.ts +16 -6
  38. package/src/overlays/agents-hub.ts +7 -18
  39. package/src/overlays/copy-selector.ts +8 -31
  40. package/src/overlays/model-browser.ts +106 -25
  41. package/src/overlays/model-hub.ts +49 -16
  42. package/src/overlays/rewind-selector.ts +60 -13
  43. package/src/overlays/usage-dashboard.ts +47 -6
  44. package/src/prompt/composer-attachments.ts +54 -7
  45. package/src/prompt/composer-cache.ts +217 -196
  46. package/src/prompt/composer.ts +45 -72
  47. package/src/prompt/custom-editor.ts +24 -6
  48. package/src/prompt/model-mention-autocomplete.ts +22 -10
  49. package/src/prompt/welcome.ts +31 -28
  50. package/src/render/width-aware-text.ts +9 -0
  51. package/src/status-line/component.ts +286 -182
  52. package/src/status-line/metrics.ts +3 -21
  53. package/src/status-line/segments.ts +68 -79
  54. package/src/status-line/startup.ts +181 -0
  55. package/src/status-line/types.ts +0 -2
  56. package/src/terminal-capabilities.ts +12 -6
  57. package/src/terminal.ts +75 -20
  58. package/src/theme/color.ts +15 -0
  59. package/src/theme/session-color.ts +26 -4
  60. package/src/theme/shimmer.ts +55 -76
  61. package/src/theme/theme-class.ts +29 -12
  62. package/src/tools/output-meta.ts +11 -3
  63. package/src/tools/vibe.ts +15 -2
  64. package/src/tui.ts +208 -154
  65. package/src/utils.ts +15 -0
@@ -1,5 +1,8 @@
1
- import type { EditorTopBorder } from "../components/composer/types";
1
+ import { getComposerStyle } from "../components/composer/registry";
2
2
  import { Spacer } from "../components/spacer";
3
+ import type { StatusLineComponent } from "../status-line/component";
4
+ import type { StatusLineSession } from "../status-line/host";
5
+ import { createStartupStatusLine, type StatusLineStartupData } from "../status-line/startup";
3
6
  import { isInsideTerminalMultiplexer } from "../terminal-multiplexer";
4
7
  import { ProcessTerminal, type Terminal } from "../terminal";
5
8
  import {
@@ -12,7 +15,7 @@ import {
12
15
  type TUIOptions,
13
16
  type ViewportSize,
14
17
  } from "../tui";
15
- import { sliceWithWidth, truncateToWidth, visibleWidth } from "../utils";
18
+ import { sliceWithWidth, visibleWidth } from "../utils";
16
19
  import { postmortem } from "@oh-my-pi/pi-utils";
17
20
  import { CustomEditor } from "./custom-editor";
18
21
  import type { WordCompletionMethod } from "./word-completion";
@@ -56,28 +59,22 @@ export interface ComposerWelcomeUpdate {
56
59
  readonly modelName?: string;
57
60
  readonly providerName?: string;
58
61
  readonly recentSessions?: readonly RecentSession[];
59
- readonly lspServers?: readonly LspServerInfo[];
62
+ /** Detected project servers; `null` means LSP is disabled and hides the welcome section. */
63
+ readonly lspServers?: readonly LspServerInfo[] | null;
60
64
  }
61
65
 
62
66
  /**
63
- * Placeholder-only status chrome replayed on the next first frame so the
64
- * status band/border exists before the session-aware status line attaches.
65
- * Bound to the composer shape it was rendered for; a different shape drops it.
67
+ * Status-bar inputs persisted by the last session. The first frame renders
68
+ * them through a startup {@link StatusLineComponent} at the live width until
69
+ * the session-aware status line attaches.
66
70
  */
67
- export interface ComposerStatusSnapshot {
68
- readonly shape: string;
69
- /** ANSI wrapper of the editor border at snapshot time (session accent or thinking color). */
71
+ export interface ComposerStatusCache {
72
+ /** ANSI wrapper of the editor border when persisted (session accent or thinking color). */
70
73
  readonly borderColor?: {
71
74
  readonly prefix: string;
72
75
  readonly suffix: string;
73
76
  };
74
- /** Status content embedded in the editor's top chrome (`top-border`, `top-band`, `top-rule-chip`). */
75
- readonly topBorder?: {
76
- readonly content: string;
77
- readonly width: number;
78
- };
79
- /** Standalone bottom-bar rows (`pi`/`claude` shapes), gap row included. */
80
- readonly bottomLines: readonly string[];
77
+ readonly statusLine: StatusLineStartupData;
81
78
  }
82
79
 
83
80
  /** Optional dependencies and initial state for a standalone composer. */
@@ -87,7 +84,7 @@ export interface ComposerOptions {
87
84
  readonly tuiOptions?: TUIOptions;
88
85
  readonly preferences?: Partial<ComposerPreferences>;
89
86
  readonly welcome?: ComposerWelcomeUpdate;
90
- readonly status?: ComposerStatusSnapshot;
87
+ readonly status?: ComposerStatusCache;
91
88
  readonly exit?: (code: number) => void;
92
89
  readonly now?: () => number;
93
90
  }
@@ -117,30 +114,16 @@ export interface ComposerStartOptions {
117
114
  readonly deferInput?: boolean;
118
115
  }
119
116
 
120
- /**
121
- * Mount slot for the session-aware status component below the editor. Shows
122
- * placeholder rows during startup until the real component mounts.
123
- */
117
+ /** Mount slot below the editor: the startup status line, then the session-aware one. */
124
118
  class StatusHost implements Component {
125
- #lines: readonly string[] = [];
126
119
  #component: Component | undefined;
127
120
 
128
- get mounted(): boolean {
129
- return this.#component !== undefined;
130
- }
131
-
132
- setLines(lines: readonly string[]): void {
133
- this.#lines = lines;
134
- }
135
-
136
121
  setComponent(component: Component): void {
137
122
  this.#component = component;
138
- this.#lines = [];
139
123
  }
140
124
 
141
125
  render(width: number): readonly string[] {
142
- if (this.#component) return this.#component.render(width);
143
- return this.#lines.map(line => truncateToWidth(line, width));
126
+ return this.#component?.render(width) ?? [];
144
127
  }
145
128
  }
146
129
 
@@ -220,11 +203,12 @@ export class Composer implements TerminalFrameProvider {
220
203
  #modelName = "";
221
204
  #providerName = "";
222
205
  #recentSessions: RecentSession[] = [];
223
- #lspServers: LspServerInfo[] = [];
206
+ #lspServers: LspServerInfo[] | null = [];
224
207
  #headerBefore: readonly Component[] = [];
225
208
  #headerAfter: readonly Component[] = [];
226
209
  #runtimeChildren: readonly Component[] = [];
227
- #statusSnapshot: ComposerStatusSnapshot | undefined;
210
+ /** Cache-driven status line shown until {@link setStatusComponent} mounts the session's. */
211
+ #startupStatus: StatusLineComponent | undefined;
228
212
  #runtimeMounted = false;
229
213
  // Composer-owned history id space. Transcript batch ids restart across
230
214
  // container clears/swaps; the composer translates them into one monotonic
@@ -291,7 +275,6 @@ export class Composer implements TerminalFrameProvider {
291
275
  this.#exit = options.exit ?? (code => postmortem.exitProcess(code));
292
276
  this.#now = options.now ?? Date.now;
293
277
  this.#preferences = { ...COMPOSER_DEFAULTS, ...options.preferences };
294
- this.#statusSnapshot = options.status;
295
278
  this.#applyWelcomeUpdate(options.welcome ?? {});
296
279
 
297
280
  this.ui = new TUI(
@@ -318,7 +301,13 @@ export class Composer implements TerminalFrameProvider {
318
301
  } catch {
319
302
  // Extension-defined styles arrive with the session; InteractiveMode reapplies them.
320
303
  }
321
- this.#applyStatusSnapshot();
304
+ if (options.status) {
305
+ const { borderColor, statusLine } = options.status;
306
+ if (borderColor) this.editor.borderColor = text => `${borderColor.prefix}${text}${borderColor.suffix}`;
307
+ this.#startupStatus = createStartupStatusLine(statusLine);
308
+ this.#statusHost.setComponent(this.#startupStatus);
309
+ this.#startupStatus.attachToEditor(this.editor, getComposerStyle(this.#preferences.composerShape));
310
+ }
322
311
  // Emergency controls stay active until InteractiveMode installs configured bindings.
323
312
  // They deliberately mirror the interactive editor's contract so a stalled startup
324
313
  // never behaves differently from a healthy one: Ctrl+C clears the draft and a second
@@ -380,12 +369,15 @@ export class Composer implements TerminalFrameProvider {
380
369
  // editor drifts up above a band of blank rows (#11007).
381
370
  this.#transientChromeFloor = Math.min(this.#transientChromeFloor ?? transientRows, transientRows);
382
371
  const belowFloor = after.length - transientRows + this.#transientChromeFloor;
372
+ const now = performance.now();
373
+ const frame: AnimationFrame = { now, tick: Math.floor(now / 80) };
374
+ // Retirement measures the same live blocks the viewport lays out below;
375
+ // one open frame renders each of them once for both.
376
+ transcript.beginFrame(frame);
383
377
  const history = this.#offerHistory(transcript, width, rows, preRoots.length + belowFloor);
384
378
  const headerVisible = !this.#headerRetired && this.#offeredHistory?.source !== "header";
385
379
  const headerRows = headerVisible ? this.#header.render(width) : [];
386
380
  const before = [...headerRows, ...preRoots];
387
- const now = performance.now();
388
- const frame: AnimationFrame = { now, tick: Math.floor(now / 80) };
389
381
  // The live tail is laid out against the same baseline retirement is
390
382
  // billed against, so its compaction allocator (one row per block, no
391
383
  // inter-block blanks) engages only when a block genuinely cannot retire.
@@ -635,7 +627,9 @@ export class Composer implements TerminalFrameProvider {
635
627
  // retires first so transcript prefixes can follow in order.
636
628
  const renderedHeader = this.#header.render(width);
637
629
  if (renderedHeader.length > 0) {
638
- const liveRows = transcript.liveRowCount(width);
630
+ // Only the comparison below reads the height, so the walk stops at
631
+ // the budget instead of rendering every replayed block (#12933).
632
+ const liveRows = transcript.liveRowCount(width, Math.max(0, rows - renderedHeader.length - chromeRows));
639
633
  if (!this.#historyFlush && renderedHeader.length + chromeRows + liveRows <= rows) return undefined;
640
634
  this.#offeredHistory = {
641
635
  id: this.#nextHistoryId++,
@@ -789,7 +783,7 @@ export class Composer implements TerminalFrameProvider {
789
783
  autocomplete: this.#preferences.spellingAutocomplete,
790
784
  autocorrect: this.#preferences.spellingAutocorrect,
791
785
  });
792
- this.#applyStatusSnapshot();
786
+ this.#startupStatus?.attachToEditor(this.editor, getComposerStyle(this.#preferences.composerShape));
793
787
  if (this.#preferences.quiet) {
794
788
  this.#welcome?.stopIntro();
795
789
  this.#welcome = undefined;
@@ -834,42 +828,20 @@ export class Composer implements TerminalFrameProvider {
834
828
  }
835
829
 
836
830
  /**
837
- * Mount the session-aware status component into the slot below the editor.
838
- * Drops the speculative snapshot; the caller installs the real top-border
831
+ * Mount the session-aware status component into the slot below the editor,
832
+ * retiring the startup status line; the caller installs the real top-border
839
833
  * provider through its composer-shape sync.
840
834
  */
841
- setStatusComponent(component: Component): void {
835
+ setStatusComponent<TSession extends StatusLineSession>(component: StatusLineComponent<TSession>): void {
836
+ if (this.#startupStatus) component.adoptGitStatus(this.#startupStatus);
837
+ this.#disposeStartupStatus();
842
838
  this.#statusHost.setComponent(component);
843
- this.#statusSnapshot = undefined;
844
839
  this.editor.setTopBorderProvider(undefined);
845
840
  }
846
841
 
847
- /** Cached placeholder top-border content fitted to the current editor width. */
848
- #speculativeTopBorder(availableWidth: number): EditorTopBorder | undefined {
849
- const border = this.#statusSnapshot?.topBorder;
850
- if (!border) return undefined;
851
- if (border.width <= availableWidth) return { content: border.content, width: border.width };
852
- const content = truncateToWidth(border.content, availableWidth);
853
- return { content, width: visibleWidth(content) };
854
- }
855
-
856
- /** Install the cached chrome for the current shape; a shape mismatch clears it. */
857
- #applyStatusSnapshot(): void {
858
- if (this.#statusHost.mounted) return;
859
- const snapshot = this.#statusSnapshot;
860
- if (!snapshot || snapshot.shape !== this.#preferences.composerShape) {
861
- this.editor.setTopBorderProvider(undefined);
862
- this.#statusHost.setLines([]);
863
- return;
864
- }
865
- if (snapshot.borderColor) {
866
- const { prefix, suffix } = snapshot.borderColor;
867
- this.editor.borderColor = text => `${prefix}${text}${suffix}`;
868
- }
869
- this.editor.setTopBorderProvider(
870
- snapshot.topBorder ? availableWidth => this.#speculativeTopBorder(availableWidth) : undefined,
871
- );
872
- this.#statusHost.setLines(snapshot.bottomLines);
842
+ #disposeStartupStatus(): void {
843
+ this.#startupStatus?.dispose();
844
+ this.#startupStatus = undefined;
873
845
  }
874
846
 
875
847
  /** Mount or replace session-aware root children while preserving the header and status hosts. */
@@ -908,6 +880,7 @@ export class Composer implements TerminalFrameProvider {
908
880
  stop(): void {
909
881
  if (!this.#started || this.#stopped || this.#transferred) return;
910
882
  this.#welcome?.stopIntro();
883
+ this.#disposeStartupStatus();
911
884
  this.ui.stop();
912
885
  this.#stopped = true;
913
886
  }
@@ -917,7 +890,7 @@ export class Composer implements TerminalFrameProvider {
917
890
  if (update.modelName !== undefined) this.#modelName = update.modelName;
918
891
  if (update.providerName !== undefined) this.#providerName = update.providerName;
919
892
  if (update.recentSessions !== undefined) this.#recentSessions = [...update.recentSessions];
920
- if (update.lspServers !== undefined) this.#lspServers = [...update.lspServers];
893
+ if (update.lspServers !== undefined) this.#lspServers = update.lspServers && [...update.lspServers];
921
894
  }
922
895
 
923
896
  #ensureWelcome(): void {
@@ -14,6 +14,7 @@ import { imageAttachmentSource } from "./image-source";
14
14
  import { isVideoPath } from "./video";
15
15
  import {
16
16
  attachmentSgr,
17
+ type ChipKind,
17
18
  COMPOSER_TOKEN_REGEX,
18
19
  chipLabel,
19
20
  collapseImageMarkers,
@@ -21,6 +22,8 @@ import {
21
22
  collapseSkillTokens,
22
23
  composerTokenRegex,
23
24
  modelChipStyle,
25
+ PLACEHOLDER_REGEX,
26
+ referencedAttachments,
24
27
  renderPlaceholders,
25
28
  skillChipLabel,
26
29
  skillChipStyle,
@@ -662,14 +665,29 @@ export class CustomEditor extends Editor {
662
665
  ) {
663
666
  return cached.chips;
664
667
  }
665
- const text = this.getText();
668
+ const recorded = new Map<string, ChipKind>();
669
+ if (this.pendingImages.length > 0) {
670
+ for (const [label, expansion] of this.atoms) {
671
+ const kind = expansion.startsWith("[Image #")
672
+ ? "image"
673
+ : expansion.startsWith("[Video #")
674
+ ? "video"
675
+ : undefined;
676
+ if (kind !== undefined && expansion.match(PLACEHOLDER_REGEX)?.[0] === expansion) {
677
+ recorded.set(label, kind);
678
+ }
679
+ }
680
+ }
681
+ for (const entry of this.pendingTexts) {
682
+ // A reused label belongs to the atom currently expanding it, not a deleted paste.
683
+ if (!recorded.has(entry.label)) recorded.set(entry.label, "paste");
684
+ }
685
+ const refs = referencedAttachments(this.getText(), recorded);
666
686
  const chips: ComposerChipDescriptor[] = [];
667
687
  for (let i = 0; i < this.pendingImages.length; i++) {
668
688
  const n = i + 1;
669
- const video =
670
- text.includes(chipLabel("video", n)) || text.includes(`[Video #${n}]`) || text.includes(`[Video #${n},`);
671
- const image =
672
- text.includes(chipLabel("image", n)) || text.includes(`[Image #${n}]`) || text.includes(`[Image #${n},`);
689
+ const video = refs.video.has(n);
690
+ const image = refs.image.has(n);
673
691
  if (!video && !image) continue;
674
692
  chips.push({
675
693
  kind: video ? "video" : "image",
@@ -679,7 +697,7 @@ export class CustomEditor extends Editor {
679
697
  });
680
698
  }
681
699
  for (const entry of this.pendingTexts) {
682
- if (!text.includes(entry.label)) continue;
700
+ if (!refs.paste.has(entry.n)) continue;
683
701
  chips.push({ kind: "paste", n: entry.n, text: entry });
684
702
  }
685
703
  this.#composerChipsCache = {
@@ -4,9 +4,11 @@ import type { ModelBrowserRegistry, ModelBrowserSource } from "../overlays/model
4
4
  import { modelMentionDisplayName } from "./model-mention-syntax";
5
5
  import type {
6
6
  buildSearchAffinity as BuildSearchAffinity,
7
- buildSessionModelScope as BuildSessionModelScope,
8
7
  ModelBrowserItem,
9
8
  rankModelItems as RankModelItems,
9
+ SearchAffinity,
10
+ SessionModelScope,
11
+ SessionModelScopeCache as SessionModelScopeCacheClass,
10
12
  } from "../overlays/model-browser";
11
13
  import { theme } from "../theme/theme";
12
14
 
@@ -17,7 +19,7 @@ const MAX_MODEL_MENTION_SUGGESTIONS = 20;
17
19
  export type ModelMentionCandidateSource = (query: string) => ReadonlyArray<ModelBrowserItem>;
18
20
 
19
21
  interface ModelBrowserModules {
20
- buildSessionModelScope: typeof BuildSessionModelScope;
22
+ SessionModelScopeCache: typeof SessionModelScopeCacheClass;
21
23
  buildSearchAffinity: typeof BuildSearchAffinity;
22
24
  rankModelItems: typeof RankModelItems;
23
25
  }
@@ -75,20 +77,30 @@ export function applyModelMentionCompletion(
75
77
  };
76
78
  }
77
79
 
78
- /** Create a fresh session-scoped model candidate lookup using picker ordering. */
80
+ /**
81
+ * Create a session-scoped model candidate lookup using picker ordering. The
82
+ * scope and search affinity are reused across queries until their inputs change.
83
+ */
79
84
  export function createModelMentionSource(host: {
80
85
  source: ModelBrowserSource;
81
86
  registry: ModelBrowserRegistry;
82
87
  scopedModels: () => ReadonlyArray<Model>;
83
88
  }): ModelMentionCandidateSource {
89
+ let scopeCache: SessionModelScopeCacheClass | undefined;
90
+ let affinityScope: SessionModelScope | undefined;
91
+ let affinityProviderOrder: readonly string[] | undefined;
92
+ let affinity: SearchAffinity | undefined;
84
93
  return query => {
85
- const { buildSearchAffinity, buildSessionModelScope, rankModelItems } = loadModelBrowser();
86
- const scope = buildSessionModelScope(host.source, host.registry, host.scopedModels());
94
+ const { SessionModelScopeCache, buildSearchAffinity, rankModelItems } = loadModelBrowser();
95
+ scopeCache ??= new SessionModelScopeCache(host.source, host.registry);
96
+ const scope = scopeCache.get(host.scopedModels());
87
97
  if (!query.trim()) return scope.items;
88
- return rankModelItems(query, scope.items, {
89
- roles: scope.roles,
90
- mruOrder: scope.mruOrder,
91
- affinity: buildSearchAffinity(host.source.modelProviderOrder, scope.roles, scope.mruOrder),
92
- });
98
+ const providerOrder = host.source.modelProviderOrder;
99
+ if (!affinity || affinityScope !== scope || affinityProviderOrder !== providerOrder) {
100
+ affinity = buildSearchAffinity(providerOrder, scope.roles, scope.mruOrder);
101
+ affinityScope = scope;
102
+ affinityProviderOrder = providerOrder;
103
+ }
104
+ return rankModelItems(query, scope.items, { roles: scope.roles, mruOrder: scope.mruOrder, affinity });
93
105
  };
94
106
  }
@@ -186,7 +186,8 @@ export class WelcomeComponent implements Component {
186
186
  private modelName: string,
187
187
  private providerName: string,
188
188
  private recentSessions: RecentSession[] = [],
189
- private lspServers: LspServerInfo[] = [],
189
+ /** Detected project servers; `null` means LSP is disabled and hides the section. */
190
+ private lspServers: LspServerInfo[] | null = [],
190
191
  ) {}
191
192
  get tip(): string | undefined {
192
193
  this.#nagRoll ??= Math.random();
@@ -272,7 +273,7 @@ export class WelcomeComponent implements Component {
272
273
  this.invalidate();
273
274
  }
274
275
 
275
- setLspServers(servers: LspServerInfo[]): void {
276
+ setLspServers(servers: LspServerInfo[] | null): void {
276
277
  this.lspServers = servers;
277
278
  this.invalidate();
278
279
  }
@@ -365,29 +366,6 @@ export class WelcomeComponent implements Component {
365
366
  sessionLines.push("");
366
367
  }
367
368
 
368
- // LSP servers content
369
- const lspLines: string[] = [];
370
- if (this.lspServers.length === 0) {
371
- lspLines.push(` ${theme.fg("dim", "No LSP servers")}`);
372
- } else {
373
- for (const server of this.lspServers.slice(0, WELCOME_LSP_SLOTS)) {
374
- const icon =
375
- server.status === "ready"
376
- ? theme.styledSymbol("status.enabled", "success")
377
- : server.status === "available"
378
- ? theme.styledSymbol("status.enabled", "dim")
379
- : server.status === "connecting"
380
- ? theme.styledSymbol("status.pending", "muted")
381
- : theme.styledSymbol("status.error", "error");
382
- const exts = server.fileTypes.slice(0, 3).join(" ");
383
- lspLines.push(` ${icon} ${theme.fg("muted", server.name)} ${theme.fg("dim", exts)}`);
384
- }
385
- }
386
- // Pad to the fixed slot count so the box height doesn't depend on server count.
387
- while (lspLines.length < WELCOME_LSP_SLOTS) {
388
- lspLines.push("");
389
- }
390
-
391
369
  // Right column
392
370
  const rightLines = [
393
371
  ` ${theme.bold(theme.fg("accent", "Tips"))}`,
@@ -395,9 +373,7 @@ export class WelcomeComponent implements Component {
395
373
  ` ${theme.fg("dim", "/")}${theme.fg("muted", " for commands")}`,
396
374
  ` ${theme.fg("dim", "!")}${theme.fg("muted", " to run bash")}`,
397
375
  ` ${theme.fg("dim", "$")}${theme.fg("muted", " to run python")}`,
398
- separator,
399
- ` ${theme.bold(theme.fg("accent", "LSP Servers"))}`,
400
- ...lspLines,
376
+ ...this.#renderLspSection(separator),
401
377
  separator,
402
378
  ` ${theme.bold(theme.fg("accent", "Recent sessions"))}`,
403
379
  ...sessionLines,
@@ -452,6 +428,33 @@ export class WelcomeComponent implements Component {
452
428
  return lines;
453
429
  }
454
430
 
431
+ /** Right-column LSP rows padded to a fixed height; empty when LSP is disabled. */
432
+ #renderLspSection(separator: string): string[] {
433
+ if (this.lspServers === null) return [];
434
+ const lspLines: string[] = [];
435
+ if (this.lspServers.length === 0) {
436
+ lspLines.push(` ${theme.fg("dim", "No LSP servers")}`);
437
+ } else {
438
+ for (const server of this.lspServers.slice(0, WELCOME_LSP_SLOTS)) {
439
+ const icon =
440
+ server.status === "ready"
441
+ ? theme.styledSymbol("status.enabled", "success")
442
+ : server.status === "available"
443
+ ? theme.styledSymbol("status.enabled", "dim")
444
+ : server.status === "connecting"
445
+ ? theme.styledSymbol("status.pending", "muted")
446
+ : theme.styledSymbol("status.error", "error");
447
+ const exts = server.fileTypes.slice(0, 3).join(" ");
448
+ lspLines.push(` ${icon} ${theme.fg("muted", server.name)} ${theme.fg("dim", exts)}`);
449
+ }
450
+ }
451
+ // Pad to the fixed slot count so the box height doesn't depend on server count.
452
+ while (lspLines.length < WELCOME_LSP_SLOTS) {
453
+ lspLines.push("");
454
+ }
455
+ return [separator, ` ${theme.bold(theme.fg("accent", "LSP Servers"))}`, ...lspLines];
456
+ }
457
+
455
458
  /**
456
459
  * Render the per-instance tip line: the `customMessageLabel`-themed `Tip:`
457
460
  * label followed by a `muted` body, the whole line italicized. Returns `[]`
@@ -47,6 +47,15 @@ export class WidthAwareText implements Component {
47
47
  this.#inner.invalidate();
48
48
  }
49
49
 
50
+ /**
51
+ * Re-run the formatter on the next render because its inputs changed.
52
+ * Unlike {@link invalidate}, the inner `Text` keeps its wrap cache, so a
53
+ * reformat that yields the same string skips re-wrapping and re-tinting.
54
+ */
55
+ reformat(): void {
56
+ this.#cachedText = undefined;
57
+ }
58
+
50
59
  render(width: number): readonly string[] {
51
60
  const paddingX = this.#ignoreTight ? this.#paddingX : getPaddingX(this.#paddingX);
52
61
  const contentWidth = Math.max(1, width - paddingX * 2);