@oh-my-pi/pi-coding-agent 15.6.0 → 15.7.1

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 (140) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/dist/types/capability/rule-buckets.d.ts +30 -0
  3. package/dist/types/capability/rule.d.ts +7 -0
  4. package/dist/types/cli/completion-gen.d.ts +80 -0
  5. package/dist/types/commands/complete.d.ts +6 -0
  6. package/dist/types/commands/completions.d.ts +13 -0
  7. package/dist/types/commands/setup.d.ts +10 -1
  8. package/dist/types/config/settings-schema.d.ts +170 -10
  9. package/dist/types/discovery/builtin-defaults.d.ts +1 -0
  10. package/dist/types/discovery/builtin-rules/index.d.ts +7 -0
  11. package/dist/types/discovery/index.d.ts +1 -0
  12. package/dist/types/edit/hashline/block-resolver.d.ts +9 -0
  13. package/dist/types/edit/hashline/index.d.ts +1 -0
  14. package/dist/types/eval/py/kernel.d.ts +3 -0
  15. package/dist/types/eval/py/runtime.d.ts +11 -1
  16. package/dist/types/export/html/template.generated.d.ts +1 -1
  17. package/dist/types/main.d.ts +1 -0
  18. package/dist/types/modes/components/index.d.ts +1 -0
  19. package/dist/types/modes/components/segment-track.d.ts +22 -0
  20. package/dist/types/modes/components/welcome.d.ts +21 -0
  21. package/dist/types/modes/interactive-mode.d.ts +3 -2
  22. package/dist/types/modes/setup-wizard/index.d.ts +16 -0
  23. package/dist/types/modes/setup-wizard/scenes/glyph.d.ts +2 -0
  24. package/dist/types/modes/setup-wizard/scenes/outro.d.ts +2 -0
  25. package/dist/types/modes/setup-wizard/scenes/providers.d.ts +2 -0
  26. package/dist/types/modes/setup-wizard/scenes/sign-in.d.ts +19 -0
  27. package/dist/types/modes/setup-wizard/scenes/splash.d.ts +11 -0
  28. package/dist/types/modes/setup-wizard/scenes/theme.d.ts +2 -0
  29. package/dist/types/modes/setup-wizard/scenes/types.d.ts +43 -0
  30. package/dist/types/modes/setup-wizard/scenes/web-search.d.ts +19 -0
  31. package/dist/types/modes/setup-wizard/wizard-overlay.d.ts +14 -0
  32. package/dist/types/modes/theme/shimmer.d.ts +2 -0
  33. package/dist/types/modes/theme/theme.d.ts +11 -0
  34. package/dist/types/modes/types.d.ts +5 -1
  35. package/dist/types/tiny/device.d.ts +78 -0
  36. package/dist/types/tiny/dtype.d.ts +85 -0
  37. package/dist/types/tiny/models.d.ts +6 -6
  38. package/dist/types/tiny/text.d.ts +15 -0
  39. package/dist/types/tiny/title-client.d.ts +8 -0
  40. package/dist/types/tools/bash.d.ts +0 -1
  41. package/dist/types/tools/eval.d.ts +1 -1
  42. package/dist/types/tools/index.d.ts +0 -1
  43. package/dist/types/tui/code-cell.d.ts +2 -0
  44. package/dist/types/tui/output-block.d.ts +17 -0
  45. package/package.json +9 -9
  46. package/src/capability/rule-buckets.ts +64 -0
  47. package/src/capability/rule.ts +8 -0
  48. package/src/cli/completion-gen.ts +550 -0
  49. package/src/cli/setup-cli.ts +5 -3
  50. package/src/cli-commands.ts +2 -0
  51. package/src/cli.ts +1 -7
  52. package/src/commands/complete.ts +66 -0
  53. package/src/commands/completions.ts +60 -0
  54. package/src/commands/setup.ts +29 -4
  55. package/src/config/settings-schema.ts +70 -11
  56. package/src/discovery/builtin-defaults.ts +39 -0
  57. package/src/discovery/builtin-rules/index.ts +48 -0
  58. package/src/discovery/builtin-rules/rs-box-leak.md +48 -0
  59. package/src/discovery/builtin-rules/rs-future-prelude.md +23 -0
  60. package/src/discovery/builtin-rules/rs-lazylock.md +51 -0
  61. package/src/discovery/builtin-rules/rs-match-ergonomics.md +67 -0
  62. package/src/discovery/builtin-rules/rs-parking-lot.md +44 -0
  63. package/src/discovery/builtin-rules/rs-result-type.md +19 -0
  64. package/src/discovery/builtin-rules/ts-bare-catch.md +38 -0
  65. package/src/discovery/builtin-rules/ts-import-type.md +42 -0
  66. package/src/discovery/builtin-rules/ts-no-any.md +56 -0
  67. package/src/discovery/builtin-rules/ts-no-dynamic-import.md +39 -0
  68. package/src/discovery/builtin-rules/ts-no-return-type.md +45 -0
  69. package/src/discovery/builtin-rules/ts-no-tiny-functions.md +50 -0
  70. package/src/discovery/builtin-rules/ts-promise-with-resolvers.md +65 -0
  71. package/src/discovery/builtin-rules/ts-set-map.md +28 -0
  72. package/src/discovery/index.ts +1 -0
  73. package/src/edit/hashline/block-resolver.ts +14 -0
  74. package/src/edit/hashline/diff.ts +4 -1
  75. package/src/edit/hashline/execute.ts +2 -1
  76. package/src/edit/hashline/index.ts +1 -0
  77. package/src/eval/py/kernel.ts +37 -15
  78. package/src/eval/py/runtime.ts +57 -28
  79. package/src/export/html/template.generated.ts +1 -1
  80. package/src/export/html/template.js +0 -12
  81. package/src/export/ttsr.ts +2 -0
  82. package/src/internal-urls/docs-index.generated.ts +7 -8
  83. package/src/main.ts +18 -1
  84. package/src/modes/components/hook-selector.ts +15 -17
  85. package/src/modes/components/index.ts +1 -0
  86. package/src/modes/components/segment-track.ts +52 -0
  87. package/src/modes/components/tips.txt +2 -1
  88. package/src/modes/components/tool-execution.ts +5 -1
  89. package/src/modes/components/welcome.ts +47 -42
  90. package/src/modes/controllers/input-controller.ts +12 -21
  91. package/src/modes/interactive-mode.ts +17 -5
  92. package/src/modes/setup-wizard/index.ts +88 -0
  93. package/src/modes/setup-wizard/scenes/glyph.ts +96 -0
  94. package/src/modes/setup-wizard/scenes/outro.ts +35 -0
  95. package/src/modes/setup-wizard/scenes/providers.ts +69 -0
  96. package/src/modes/setup-wizard/scenes/sign-in.ts +193 -0
  97. package/src/modes/setup-wizard/scenes/splash.ts +201 -0
  98. package/src/modes/setup-wizard/scenes/theme.ts +299 -0
  99. package/src/modes/setup-wizard/scenes/types.ts +48 -0
  100. package/src/modes/setup-wizard/scenes/web-search.ts +128 -0
  101. package/src/modes/setup-wizard/wizard-overlay.ts +275 -0
  102. package/src/modes/theme/shimmer.ts +5 -0
  103. package/src/modes/theme/theme.ts +44 -20
  104. package/src/modes/types.ts +6 -1
  105. package/src/prompts/system/orchestrate-notice.md +1 -1
  106. package/src/prompts/tools/read.md +4 -0
  107. package/src/sdk.ts +5 -15
  108. package/src/slash-commands/builtin-registry.ts +8 -0
  109. package/src/tiny/device.ts +117 -0
  110. package/src/tiny/dtype.ts +101 -0
  111. package/src/tiny/models.ts +7 -6
  112. package/src/tiny/text.ts +36 -1
  113. package/src/tiny/title-client.ts +58 -3
  114. package/src/tiny/worker.ts +93 -29
  115. package/src/tools/bash.ts +16 -13
  116. package/src/tools/eval.ts +9 -4
  117. package/src/tools/index.ts +0 -11
  118. package/src/tools/read.ts +1 -0
  119. package/src/tools/renderers.ts +0 -2
  120. package/src/tui/code-cell.ts +6 -1
  121. package/src/tui/output-block.ts +199 -38
  122. package/dist/types/tools/recipe/index.d.ts +0 -46
  123. package/dist/types/tools/recipe/render.d.ts +0 -36
  124. package/dist/types/tools/recipe/runner.d.ts +0 -60
  125. package/dist/types/tools/recipe/runners/cargo.d.ts +0 -16
  126. package/dist/types/tools/recipe/runners/index.d.ts +0 -2
  127. package/dist/types/tools/recipe/runners/just.d.ts +0 -2
  128. package/dist/types/tools/recipe/runners/make.d.ts +0 -2
  129. package/dist/types/tools/recipe/runners/pkg.d.ts +0 -2
  130. package/dist/types/tools/recipe/runners/task.d.ts +0 -2
  131. package/src/prompts/tools/recipe.md +0 -16
  132. package/src/tools/recipe/index.ts +0 -81
  133. package/src/tools/recipe/render.ts +0 -19
  134. package/src/tools/recipe/runner.ts +0 -219
  135. package/src/tools/recipe/runners/cargo.ts +0 -131
  136. package/src/tools/recipe/runners/index.ts +0 -8
  137. package/src/tools/recipe/runners/just.ts +0 -73
  138. package/src/tools/recipe/runners/make.ts +0 -101
  139. package/src/tools/recipe/runners/pkg.ts +0 -167
  140. package/src/tools/recipe/runners/task.ts +0 -72
package/src/main.ts CHANGED
@@ -49,6 +49,7 @@ import {
49
49
  } from "./extensibility/plugins/marketplace";
50
50
  import type { MCPManager } from "./mcp";
51
51
  import { InteractiveMode, runAcpMode, runPrintMode, runRpcMode } from "./modes";
52
+ import { ALL_SCENES, runSetupWizard, selectSetupScenes } from "./modes/setup-wizard";
52
53
  import { initTheme, stopThemeWatcher } from "./modes/theme/theme";
53
54
  import type { SubmittedUserInput } from "./modes/types";
54
55
  import {
@@ -257,6 +258,8 @@ async function runInteractiveMode(
257
258
  setExtensionUIContext: (uiContext: ExtensionUIContext, hasUI: boolean) => void,
258
259
  lspServers: LspStartupServerInfo[] | undefined,
259
260
  mcpManager: MCPManager | undefined,
261
+ resuming: boolean,
262
+ forceSetupWizard: boolean,
260
263
  eventBus?: EventBus,
261
264
  initialMessage?: string,
262
265
  initialImages?: ImageContent[],
@@ -271,7 +274,18 @@ async function runInteractiveMode(
271
274
  eventBus,
272
275
  );
273
276
 
274
- await mode.init();
277
+ const setupScenes = await selectSetupScenes(settings.get("setupVersion"), ALL_SCENES, mode, {
278
+ resuming,
279
+ isTTY: process.stdin.isTTY && process.stdout.isTTY,
280
+ setupWizardEnabled: settings.get("startup.setupWizard"),
281
+ force: forceSetupWizard,
282
+ });
283
+
284
+ await mode.init({ suppressWelcomeIntro: setupScenes.length > 0 });
285
+
286
+ if (setupScenes.length > 0) {
287
+ await runSetupWizard(mode, setupScenes);
288
+ }
275
289
 
276
290
  versionCheckPromise
277
291
  .then(newVersion => {
@@ -693,6 +707,7 @@ interface RunRootCommandDependencies {
693
707
  discoverAuthStorage?: typeof discoverAuthStorage;
694
708
  runAcpMode?: typeof runAcpMode;
695
709
  settings?: Settings;
710
+ forceSetupWizard?: boolean;
696
711
  }
697
712
 
698
713
  export async function runRootCommand(
@@ -1028,6 +1043,8 @@ export async function runRootCommand(
1028
1043
  setToolUIContext,
1029
1044
  lspServers,
1030
1045
  mcpManager,
1046
+ Boolean(parsedArgs.continue || parsedArgs.resume || parsedArgs.fork),
1047
+ deps.forceSetupWizard === true,
1031
1048
  eventBus,
1032
1049
  initialMessage,
1033
1050
  initialImages,
@@ -24,6 +24,7 @@ import {
24
24
  } from "../../modes/utils/keybinding-matchers";
25
25
  import { CountdownTimer } from "./countdown-timer";
26
26
  import { DynamicBorder } from "./dynamic-border";
27
+ import { renderSegmentTrack } from "./segment-track";
27
28
 
28
29
  /** One segment of a {@link HookSelectorSlider} — a label, its accent color, and
29
30
  * an optional detail line (e.g. the resolved model name) shown beneath the
@@ -206,28 +207,25 @@ export class HookSelectorComponent extends Container {
206
207
  }
207
208
  }
208
209
 
209
- /** Render the slider block: the track (dim caption, edge arrows that brighten
210
- * while there is room to move, one styled segment per option — active = bold
211
- * in its color, the rest dim, joined by `›`) plus, when the active segment
212
- * carries a `detail`, a muted second line beneath it (e.g. the resolved model
213
- * name). Returns one or two `\n`-joined lines. */
210
+ /** Render the slider block in the style of the status line: each option is a
211
+ * distinctly colored segment, the active one filled as a powerline chip
212
+ * (its accent as the background, a luminance-matched label, flanked by
213
+ * triangle caps) and the rest shown as plain colored labels joined by a thin
214
+ * separator. Edge arrows brighten while there is room to move. When the
215
+ * active segment carries a `detail` (e.g. the resolved model name) a muted
216
+ * second line is appended. Returns one or two `\n`-joined lines. */
214
217
  #renderSliderLine(): string {
215
218
  const slider = this.#slider;
216
219
  if (!slider) return "";
217
220
  const segments = slider.segments;
218
- const sep = theme.fg("dim", " › ");
219
- const track = segments
220
- .map((segment, i) =>
221
- i === this.#sliderIndex
222
- ? theme.bold(theme.fg(segment.color ?? "accent", segment.label))
223
- : theme.fg("dim", segment.label),
224
- )
225
- .join(sep);
226
- const leftArrow = theme.fg(this.#sliderIndex > 0 ? "accent" : "dim", "◂");
227
- const rightArrow = theme.fg(this.#sliderIndex < segments.length - 1 ? "accent" : "dim", "▸");
221
+ const active = this.#sliderIndex;
222
+ const track = renderSegmentTrack(segments, active);
223
+
224
+ const leftArrow = theme.fg(active > 0 ? "accent" : "dim", "◂");
225
+ const rightArrow = theme.fg(active < segments.length - 1 ? "accent" : "dim", "▸");
228
226
  const caption = slider.caption ? `${theme.fg("dim", slider.caption)} ` : "";
229
- const trackLine = `${caption}${leftArrow} ${theme.fg("dim", "[")} ${track} ${theme.fg("dim", "]")} ${rightArrow}`;
230
- const detail = segments[this.#sliderIndex]?.detail;
227
+ const trackLine = `${caption}${leftArrow} ${track} ${rightArrow}`;
228
+ const detail = segments[active]?.detail;
231
229
  if (!detail) return trackLine;
232
230
  return `${trackLine}\n ${theme.fg("dim", "↳")} ${theme.fg("muted", detail)}`;
233
231
  }
@@ -20,6 +20,7 @@ export * from "./model-selector";
20
20
  export * from "./oauth-selector";
21
21
  export * from "./queue-mode-selector";
22
22
  export * from "./read-tool-group";
23
+ export * from "./segment-track";
23
24
  export * from "./session-selector";
24
25
  export * from "./settings-selector";
25
26
  export * from "./show-images-selector";
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Shared renderer for a horizontal row of colored "segments" styled after the
3
+ * status line: each segment shows in its own accent, the active one is filled
4
+ * as a powerline chip (its accent as the background, a luminance-matched label,
5
+ * flanked by triangle caps) and the rest are plain colored labels joined by a
6
+ * thin separator.
7
+ *
8
+ * Used by the plan-mode model-tier slider ({@link HookSelectorComponent}) and
9
+ * the ctrl+p role-cycle status so both surfaces read identically.
10
+ */
11
+ import { type ThemeColor, theme } from "../theme/theme";
12
+
13
+ export interface TrackSegment {
14
+ label: string;
15
+ /** Theme color for the segment; defaults to `accent`. */
16
+ color?: ThemeColor;
17
+ }
18
+
19
+ const FG_RESET = "\x1b[39m";
20
+ const BG_RESET = "\x1b[49m";
21
+
22
+ /**
23
+ * Render `segments` as a colored chip track with `activeIndex` filled. Returns
24
+ * a single line of styled text with no surrounding caption or arrows — callers
25
+ * frame it as they need.
26
+ */
27
+ export function renderSegmentTrack(segments: TrackSegment[], activeIndex: number): string {
28
+ // Powerline triangles point *into* the chip so the colored caps merge with
29
+ // the filled body: left cap points left, right cap points right.
30
+ const capLeft = theme.sep.powerlineRight;
31
+ const capRight = theme.sep.powerlineLeft;
32
+ const thinSep = theme.fg("statusLineSep", theme.sep.powerlineThin);
33
+
34
+ let track = "";
35
+ segments.forEach((segment, i) => {
36
+ if (i > 0) {
37
+ // A thin separator reads cleanly only between two plain labels; the chip
38
+ // caps already delimit the active segment, so pad around it instead.
39
+ track += i === activeIndex || i - 1 === activeIndex ? " " : ` ${thinSep} `;
40
+ }
41
+ const color = segment.color ?? "accent";
42
+ const fg = theme.getFgAnsi(color);
43
+ if (i !== activeIndex) {
44
+ track += `${fg}${segment.label}${FG_RESET}`;
45
+ return;
46
+ }
47
+ const bg = fg.replace("\x1b[38;", "\x1b[48;");
48
+ const label = `${bg}${theme.getContrastFgAnsi(color)}\x1b[1m ${segment.label} \x1b[22m${BG_RESET}`;
49
+ track += `${fg}${capLeft}${label}${fg}${capRight}${FG_RESET}`;
50
+ });
51
+ return track;
52
+ }
@@ -9,4 +9,5 @@ Did you know? Each kitty/tmux split keeps its own session — `omp -c` resumes t
9
9
  Drop the word `ultrathink` in your message for harder multi-step reasoning — watch it glow rainbow as you type
10
10
  Say `orchestrate` in your message to drive a multi-phase task with parallel subagents — watch it glow as you type
11
11
  Log in to several accounts of the same provider — `/login` again — and omp load-balances across them automatically
12
- Run `omp auth-broker serve` once and every machine pulls live tokens over the wire — refresh keys never leave the host; `omp auth-gateway` fronts it as a drop-in proxy any OpenAI-compatible client can hit
12
+ Run `omp auth-broker serve` once and every machine pulls live tokens over the wire — refresh keys never leave the host; `omp auth-gateway` fronts it as a drop-in proxy any OpenAI-compatible client can hit
13
+ Press alt+p (or /switch) to switch provider, and ctrl+p to cycle role models smol -> slow -> etc
@@ -15,6 +15,7 @@ import {
15
15
  } from "@oh-my-pi/pi-tui";
16
16
  import { getProjectDir, logger, sanitizeText } from "@oh-my-pi/pi-utils";
17
17
  import { EDIT_MODE_STRATEGIES, type EditMode, type PerFileDiffPreview } from "../../edit";
18
+ import { shimmerEnabled } from "../../modes/theme/shimmer";
18
19
  import type { Theme } from "../../modes/theme/theme";
19
20
  import { theme } from "../../modes/theme/theme";
20
21
  import { BASH_DEFAULT_PREVIEW_LINES } from "../../tools/bash";
@@ -380,7 +381,10 @@ export class ToolExecutionComponent extends Container {
380
381
  this.#toolName === "task" &&
381
382
  (this.#result?.details as { async?: { state?: string } } | undefined)?.async?.state === "running";
382
383
  const isPartialTask = this.#isPartial && this.#toolName === "task" && !isBackgroundAsyncTask;
383
- const needsSpinner = isStreamingArgs || isPartialTask;
384
+ // Sweep the border of bash/eval execution blocks while they're pending.
385
+ const isPendingExecBlock =
386
+ this.#isPartial && shimmerEnabled() && (this.#toolName === "bash" || this.#toolName === "eval");
387
+ const needsSpinner = isStreamingArgs || isPartialTask || isPendingExecBlock;
384
388
  if (needsSpinner && !this.#spinnerInterval) {
385
389
  this.#spinnerInterval = setInterval(() => {
386
390
  const frameCount = theme.spinnerFrames.length;
@@ -325,8 +325,7 @@ export class WelcomeComponent implements Component {
325
325
  }
326
326
  }
327
327
 
328
- // biome-ignore format: preserve ASCII art layout
329
- const PI_LOGO = ["▀██████████▀", " ╘██ ██ ", " ██ ██ ", " ██ ██ ", " ▄██▄ ▄██▄ "];
328
+ export const PI_LOGO = ["▀██████████▀", " ╘██ ██ ", " ██ ██ ", " ██ ██ ", " ▄██▄ ▄██▄ "];
330
329
 
331
330
  /** Multi-stop palette for the diagonal gradient. */
332
331
  const GRADIENT_STOPS: ReadonlyArray<readonly [number, number, number]> = [
@@ -343,63 +342,69 @@ const GRADIENT_RAMP_256 = [199, 171, 135, 99, 75, 51, 87];
343
342
  /** Half-width of the shine highlight band, expressed in gradient-t units. */
344
343
  const SHINE_HALF_WIDTH = 0.18;
345
344
 
346
- interface ShineConfig {
345
+ export interface ShineConfig {
347
346
  /** Overall opacity of the shine overlay, in [0, 1]. */
348
347
  strength: number;
349
348
  /** Center of the shine band along the diagonal, in [0, 1]. */
350
349
  pos: number;
351
350
  }
352
351
 
352
+ /**
353
+ * Resolve the gradient SGR foreground escape for a normalized position `t`
354
+ * (0..1) along the diagonal, compositing the optional sliding shine highlight.
355
+ * Shared by {@link gradientLogo} and the setup splash so both stay
356
+ * color-identical (truecolor when available, 256-color ramp otherwise).
357
+ */
358
+ export function gradientEscape(t: number, shine?: ShineConfig): string {
359
+ const shineStrength = shine && shine.strength > 0 ? shine.strength : 0;
360
+ const shinePos = shine ? shine.pos : 0;
361
+ if (TERMINAL.trueColor) {
362
+ // 5-stop palette widens the visible color range and avoids the
363
+ // deep-blue valley a naive HSL lerp falls into.
364
+ const stops = GRADIENT_STOPS;
365
+ const seg = t * (stops.length - 1);
366
+ const i = Math.min(stops.length - 2, Math.floor(seg));
367
+ const f = seg - i;
368
+ const a = stops[i];
369
+ const b = stops[i + 1];
370
+ let r = a[0] + (b[0] - a[0]) * f;
371
+ let g = a[1] + (b[1] - a[1]) * f;
372
+ let bl = a[2] + (b[2] - a[2]) * f;
373
+ if (shineStrength > 0) {
374
+ const dist = Math.abs(t - shinePos);
375
+ const intensity = Math.max(0, 1 - dist / SHINE_HALF_WIDTH) * shineStrength;
376
+ if (intensity > 0) {
377
+ r += (255 - r) * intensity;
378
+ g += (255 - g) * intensity;
379
+ bl += (255 - bl) * intensity;
380
+ }
381
+ }
382
+ return `\x1b[38;2;${Math.round(r)};${Math.round(g)};${Math.round(bl)}m`;
383
+ }
384
+ const ramp = GRADIENT_RAMP_256;
385
+ let idx = Math.min(ramp.length - 1, Math.max(0, Math.floor(t * (ramp.length - 1) + 0.5)));
386
+ if (shineStrength > 0) {
387
+ const dist = Math.abs(t - shinePos);
388
+ const intensity = Math.max(0, 1 - dist / SHINE_HALF_WIDTH) * shineStrength;
389
+ // Promote to the brightest ramp slot when the shine band peaks here.
390
+ if (intensity > 0.5) idx = ramp.length - 1;
391
+ }
392
+ return `\x1b[38;5;${ramp[idx]}m`;
393
+ }
394
+
353
395
  /**
354
396
  * Apply a multi-stop diagonal gradient (bottom-left → top-right) plus an
355
397
  * optional sliding shine band across multi-line art. `phase` (0..1) shifts the
356
398
  * gradient along the diagonal, wrapping at 1. When `shine` is provided, a soft
357
399
  * white highlight is composited on top, centered at `shine.pos`.
358
400
  */
359
- function gradientLogo(lines: readonly string[], phase = 0, shine?: ShineConfig): string[] {
401
+ export function gradientLogo(lines: readonly string[], phase = 0, shine?: ShineConfig): string[] {
360
402
  const reset = "\x1b[0m";
361
403
  const rows = lines.length;
362
404
  const cols = Math.max(...lines.map(l => l.length));
363
405
  // span+1 so `base` stays strictly < 1: avoids the wrap-around at the
364
406
  // far corner mapping back to t=0 (hot pink) on the resting frame.
365
407
  const span = Math.max(1, cols + rows - 1);
366
- const shineStrength = shine && shine.strength > 0 ? shine.strength : 0;
367
- const shinePos = shine ? shine.pos : 0;
368
- const colorAt = TERMINAL.trueColor
369
- ? (t: number): string => {
370
- // 5-stop palette widens the visible color range and avoids the
371
- // deep-blue valley a naive HSL lerp falls into.
372
- const stops = GRADIENT_STOPS;
373
- const seg = t * (stops.length - 1);
374
- const i = Math.min(stops.length - 2, Math.floor(seg));
375
- const f = seg - i;
376
- const a = stops[i];
377
- const b = stops[i + 1];
378
- let r = a[0] + (b[0] - a[0]) * f;
379
- let g = a[1] + (b[1] - a[1]) * f;
380
- let bl = a[2] + (b[2] - a[2]) * f;
381
- if (shineStrength > 0) {
382
- const dist = Math.abs(t - shinePos);
383
- const intensity = Math.max(0, 1 - dist / SHINE_HALF_WIDTH) * shineStrength;
384
- if (intensity > 0) {
385
- r += (255 - r) * intensity;
386
- g += (255 - g) * intensity;
387
- bl += (255 - bl) * intensity;
388
- }
389
- }
390
- return `\x1b[38;2;${Math.round(r)};${Math.round(g)};${Math.round(bl)}m`;
391
- }
392
- : (t: number): string => {
393
- const ramp = GRADIENT_RAMP_256;
394
- let idx = Math.min(ramp.length - 1, Math.max(0, Math.floor(t * (ramp.length - 1) + 0.5)));
395
- if (shineStrength > 0) {
396
- const dist = Math.abs(t - shinePos);
397
- const intensity = Math.max(0, 1 - dist / SHINE_HALF_WIDTH) * shineStrength;
398
- // Promote to the brightest ramp slot when the shine band peaks here.
399
- if (intensity > 0.5) idx = ramp.length - 1;
400
- }
401
- return `\x1b[38;5;${ramp[idx]}m`;
402
- };
403
408
  return lines.map((line, y) => {
404
409
  let result = "";
405
410
  for (let x = 0; x < line.length; x++) {
@@ -411,7 +416,7 @@ function gradientLogo(lines: readonly string[], phase = 0, shine?: ShineConfig):
411
416
  // Diagonal: bottom-left (x=0, y=rows-1) → top-right (x=cols-1, y=0)
412
417
  const base = (x + (rows - 1 - y)) / span;
413
418
  const t = (((base + phase) % 1) + 1) % 1;
414
- result += colorAt(t) + char + reset;
419
+ result += gradientEscape(t, shine) + char + reset;
415
420
  }
416
421
  return result;
417
422
  });
@@ -1,8 +1,10 @@
1
1
  import * as fs from "node:fs/promises";
2
- import { type AgentMessage, ThinkingLevel } from "@oh-my-pi/pi-agent-core";
2
+ import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
3
3
  import type { AutocompleteProvider, SlashCommand } from "@oh-my-pi/pi-tui";
4
4
  import { $env, sanitizeText } from "@oh-my-pi/pi-utils";
5
+ import { getRoleInfo } from "../../config/model-registry";
5
6
  import { isSettingsInitialized, settings } from "../../config/settings";
7
+ import { renderSegmentTrack } from "../../modes/components/segment-track";
6
8
  import { TinyTitleDownloadProgressComponent } from "../../modes/components/tiny-title-download-progress";
7
9
  import { expandEmoticons } from "../../modes/emoji-autocomplete";
8
10
  import { createPromptActionAutocompleteProvider } from "../../modes/prompt-action-autocomplete";
@@ -769,27 +771,16 @@ export class InputController {
769
771
 
770
772
  this.ctx.statusLine.invalidate();
771
773
  this.ctx.updateEditorBorderColor();
772
- const roleLabel = result.role === "default" ? "default" : result.role;
773
- const roleLabelStyled = theme.bold(theme.fg("accent", roleLabel));
774
- const thinkingStr =
775
- result.model.thinking && result.thinkingLevel !== ThinkingLevel.Off
776
- ? ` (thinking: ${result.thinkingLevel})`
777
- : "";
778
- const tempLabel = options?.temporary ? " (temporary)" : "";
779
- const cycleSeparator = theme.fg("dim", " > ");
780
- const cycleLabel = cycleOrder
781
- .map(role => {
782
- if (role === result.role) {
783
- return theme.bold(theme.fg("accent", role));
784
- }
785
- return theme.fg("muted", role);
786
- })
787
- .join(cycleSeparator);
788
- const orderLabel = ` (cycle: ${cycleLabel})`;
789
- this.ctx.showStatus(
790
- `Switched to ${roleLabelStyled}: ${result.model.name || result.model.id}${thinkingStr}${tempLabel}${orderLabel}`,
791
- { dim: false },
774
+ // The status line already reports the resolved model + thinking level, so
775
+ // the cycle status is just a status-line-style chip track (active role
776
+ // filled), matching the plan-approval model slider. A dim suffix flags a
777
+ // temporary switch since that isn't shown elsewhere.
778
+ const track = renderSegmentTrack(
779
+ cycleOrder.map(role => ({ label: role, color: getRoleInfo(role, settings).color })),
780
+ cycleOrder.indexOf(result.role),
792
781
  );
782
+ const tempLabel = options?.temporary ? theme.fg("dim", " (temporary)") : "";
783
+ this.ctx.showStatus(`${track}${tempLabel}`, { dim: false });
793
784
  } catch (error) {
794
785
  this.ctx.showError(error instanceof Error ? error.message : String(error));
795
786
  }
@@ -116,7 +116,14 @@ import {
116
116
  onThemeChange,
117
117
  theme,
118
118
  } from "./theme/theme";
119
- import type { CompactionQueuedMessage, InteractiveModeContext, SubmittedUserInput, TodoItem, TodoPhase } from "./types";
119
+ import type {
120
+ CompactionQueuedMessage,
121
+ InteractiveModeContext,
122
+ InteractiveModeInitOptions,
123
+ SubmittedUserInput,
124
+ TodoItem,
125
+ TodoPhase,
126
+ } from "./types";
120
127
  import { UiHelpers } from "./utils/ui-helpers";
121
128
 
122
129
  const HINT_SHIMMER_PALETTE: ShimmerPalette = {
@@ -368,7 +375,7 @@ export class InteractiveMode implements InteractiveModeContext {
368
375
  this.ui.requestRender(true);
369
376
  };
370
377
  this.editor.onAutocompleteUpdate = () => {
371
- this.ui.requestRender();
378
+ this.ui.requestRender(false, { allowUnknownViewportMutation: true });
372
379
  };
373
380
  this.#syncEditorMaxHeight();
374
381
  this.#resizeHandler = () => {
@@ -431,7 +438,10 @@ export class InteractiveMode implements InteractiveModeContext {
431
438
  this.#observerRegistry = new SessionObserverRegistry();
432
439
  }
433
440
 
434
- async init(): Promise<void> {
441
+ playWelcomeIntro(): void {
442
+ this.#welcomeComponent?.playIntro(() => this.ui.requestRender());
443
+ }
444
+ async init(options: InteractiveModeInitOptions = {}): Promise<void> {
435
445
  if (this.isInitialized) return;
436
446
 
437
447
  this.keybindings = logger.time("InteractiveMode.init:keybindings", () => KeybindingsManager.create());
@@ -489,7 +499,9 @@ export class InteractiveMode implements InteractiveModeContext {
489
499
  this.ui.addChild(new Spacer(1));
490
500
  this.ui.addChild(this.#welcomeComponent);
491
501
  this.ui.addChild(new Spacer(1));
492
- this.#welcomeComponent.playIntro(() => this.ui.requestRender());
502
+ if (!options.suppressWelcomeIntro) {
503
+ this.playWelcomeIntro();
504
+ }
493
505
 
494
506
  // Add changelog if provided
495
507
  if (this.#changelogMarkdown) {
@@ -2259,7 +2271,7 @@ export class InteractiveMode implements InteractiveModeContext {
2259
2271
  this.ui.requestRender(true);
2260
2272
  };
2261
2273
  nextEditor.onAutocompleteUpdate = () => {
2262
- this.ui.requestRender();
2274
+ this.ui.requestRender(false, { allowUnknownViewportMutation: true });
2263
2275
  };
2264
2276
  nextEditor.setMaxHeight(this.#computeEditorMaxHeight());
2265
2277
  if (this.historyStorage) {
@@ -0,0 +1,88 @@
1
+ import type { Settings } from "../../config/settings";
2
+ import type { InteractiveModeContext } from "../types";
3
+ import { glyphSetupScene } from "./scenes/glyph";
4
+ import { providersSetupScene } from "./scenes/providers";
5
+ import { themeSetupScene } from "./scenes/theme";
6
+ import type { SetupScene } from "./scenes/types";
7
+ import { SetupWizardComponent } from "./wizard-overlay";
8
+
9
+ export type { SetupScene, SetupSceneController, SetupSceneHost, SetupSceneResult } from "./scenes/types";
10
+
11
+ export const ALL_SCENES = [
12
+ providersSetupScene,
13
+ glyphSetupScene,
14
+ themeSetupScene,
15
+ ] as const satisfies readonly SetupScene[];
16
+
17
+ export const CURRENT_SETUP_VERSION = ALL_SCENES.reduce((max, scene) => Math.max(max, scene.minVersion), 0);
18
+
19
+ export interface SetupSceneSelectionOptions {
20
+ resuming?: boolean;
21
+ isTTY?: boolean;
22
+ skipEnv?: string;
23
+ setupWizardEnabled?: boolean;
24
+ force?: boolean;
25
+ }
26
+
27
+ function setupSkipEnvEnabled(value: string | undefined): boolean {
28
+ if (value === undefined) return false;
29
+ const normalized = value.trim().toLowerCase();
30
+ return normalized !== "" && normalized !== "0" && normalized !== "false" && normalized !== "no";
31
+ }
32
+
33
+ export async function selectSetupScenes(
34
+ storedVersion: number,
35
+ scenes: readonly SetupScene[],
36
+ ctx?: InteractiveModeContext,
37
+ options: SetupSceneSelectionOptions = {},
38
+ ): Promise<SetupScene[]> {
39
+ const isTTY = options.isTTY ?? (process.stdin.isTTY && process.stdout.isTTY);
40
+ if (!isTTY) return [];
41
+ if (!options.force) {
42
+ if (options.resuming) return [];
43
+ if (setupSkipEnvEnabled(options.skipEnv ?? Bun.env.OMP_SKIP_SETUP)) return [];
44
+ if (options.setupWizardEnabled === false) return [];
45
+ }
46
+
47
+ const selected: SetupScene[] = [];
48
+ for (const scene of scenes) {
49
+ if (!options.force && scene.minVersion <= storedVersion) continue;
50
+ if (scene.shouldRun) {
51
+ if (!ctx) continue;
52
+ if (!(await scene.shouldRun(ctx))) continue;
53
+ }
54
+ selected.push(scene);
55
+ }
56
+ return selected;
57
+ }
58
+
59
+ export async function markSetupWizardComplete(
60
+ settings: Settings,
61
+ version: number = CURRENT_SETUP_VERSION,
62
+ ): Promise<void> {
63
+ settings.set("setupVersion", version);
64
+ await settings.flush();
65
+ }
66
+
67
+ export async function runSetupWizard(
68
+ ctx: InteractiveModeContext,
69
+ scenes: readonly SetupScene[] = ALL_SCENES,
70
+ ): Promise<void> {
71
+ if (scenes.length === 0) return;
72
+ const component = new SetupWizardComponent(ctx, scenes);
73
+ const overlay = ctx.ui.showOverlay(component, {
74
+ width: "100%",
75
+ maxHeight: "100%",
76
+ anchor: "top-left",
77
+ margin: 0,
78
+ });
79
+ try {
80
+ await component.run();
81
+ await markSetupWizardComplete(ctx.settings);
82
+ } finally {
83
+ component.dispose();
84
+ ctx.ui.setFocus(component);
85
+ overlay.hide();
86
+ }
87
+ ctx.playWelcomeIntro();
88
+ }
@@ -0,0 +1,96 @@
1
+ import { type SelectItem, SelectList } from "@oh-my-pi/pi-tui";
2
+ import { getSelectListTheme, type SymbolPreset, setSymbolPreset, theme } from "../../theme/theme";
3
+ import type { SetupScene, SetupSceneController, SetupSceneHost } from "./types";
4
+
5
+ const GLYPH_PRESETS = ["nerd", "unicode", "ascii"] as const satisfies readonly SymbolPreset[];
6
+
7
+ const GLYPH_LABELS: Readonly<Record<SymbolPreset, string>> = {
8
+ nerd: "Nerd Font",
9
+ unicode: "Unicode",
10
+ ascii: "ASCII",
11
+ };
12
+
13
+ const GLYPH_SAMPLES: Readonly<Record<SymbolPreset, string>> = {
14
+ nerd: "   󰉋 ",
15
+ unicode: "✔ ✖ 📁 ⬢ ╭─╮ ├─ • ⠋ →",
16
+ ascii: "[ok] [x] > + [D] +-+ |-- * ->",
17
+ };
18
+
19
+ /** One picker row per preset; the description column shows live sample glyphs instead of prose. */
20
+ const GLYPH_ITEMS: readonly SelectItem[] = GLYPH_PRESETS.map((preset, index) => ({
21
+ value: preset,
22
+ label: `${index + 1} ${GLYPH_LABELS[preset]}`,
23
+ description: preset === "nerd" ? `${GLYPH_SAMPLES.nerd} ╭─╮ ├─ ◆ ✔ ✖` : GLYPH_SAMPLES[preset],
24
+ }));
25
+
26
+ class GlyphSceneController implements SetupSceneController {
27
+ title = "Choose glyph mode";
28
+ subtitle = "Pick the row that renders cleanly in your terminal.";
29
+ #selectList: SelectList;
30
+ #previewRequest = 0;
31
+ #committing = false;
32
+
33
+ constructor(private readonly host: SetupSceneHost) {
34
+ this.#selectList = new SelectList(GLYPH_ITEMS, GLYPH_ITEMS.length, getSelectListTheme());
35
+ const current = theme.getSymbolPreset();
36
+ const currentIndex = GLYPH_PRESETS.indexOf(current);
37
+ this.#selectList.setSelectedIndex(currentIndex >= 0 ? currentIndex : 0);
38
+ this.#selectList.onSelectionChange = item => {
39
+ this.#preview(item.value as SymbolPreset);
40
+ };
41
+ this.#selectList.onSelect = item => {
42
+ void this.#commit(item.value as SymbolPreset);
43
+ };
44
+ this.#selectList.onCancel = () => host.finish("skipped");
45
+ }
46
+
47
+ invalidate(): void {
48
+ this.#selectList.invalidate();
49
+ }
50
+
51
+ handleInput(data: string): void {
52
+ if (this.#committing) return;
53
+ const quickIndex = data >= "1" && data <= "3" ? Number(data) - 1 : -1;
54
+ if (quickIndex >= 0) {
55
+ const preset = GLYPH_PRESETS[quickIndex];
56
+ this.#selectList.setSelectedIndex(quickIndex);
57
+ this.#preview(preset);
58
+ return;
59
+ }
60
+ this.#selectList.handleInput(data);
61
+ }
62
+
63
+ render(width: number): string[] {
64
+ return [
65
+ theme.fg("muted", "If a row shows boxes, tofu, or misaligned icons, pick another."),
66
+ "",
67
+ ...this.#selectList.render(width),
68
+ ];
69
+ }
70
+
71
+ async #commit(preset: SymbolPreset): Promise<void> {
72
+ if (this.#committing) return;
73
+ this.#committing = true;
74
+ this.#previewRequest += 1;
75
+ this.host.ctx.settings.set("symbolPreset", preset);
76
+ await setSymbolPreset(preset);
77
+ this.host.ctx.ui.invalidate();
78
+ this.host.finish("done");
79
+ }
80
+
81
+ #preview(preset: SymbolPreset): void {
82
+ const request = ++this.#previewRequest;
83
+ void setSymbolPreset(preset).then(() => {
84
+ if (request !== this.#previewRequest || this.#committing) return;
85
+ this.host.ctx.ui.invalidate();
86
+ this.host.requestRender();
87
+ });
88
+ }
89
+ }
90
+
91
+ export const glyphSetupScene: SetupScene = {
92
+ id: "glyph-mode",
93
+ title: "Choose glyph mode",
94
+ minVersion: 1,
95
+ mount: host => new GlyphSceneController(host),
96
+ };
@@ -0,0 +1,35 @@
1
+ import { padding, truncateToWidth, visibleWidth } from "@oh-my-pi/pi-tui";
2
+ import { gradientLogo, PI_LOGO } from "../../components/welcome";
3
+ import { theme } from "../../theme/theme";
4
+ import { renderStarfield, SETUP_TICK_MS } from "./splash";
5
+
6
+ export const SETUP_OUTRO_MS = 1200;
7
+
8
+ function centerLine(line: string, width: number): string {
9
+ const lineWidth = visibleWidth(line);
10
+ if (lineWidth >= width) return truncateToWidth(line, width);
11
+ const left = Math.floor((width - lineWidth) / 2);
12
+ return padding(left) + line + padding(width - left - lineWidth);
13
+ }
14
+
15
+ function clampLine(line: string, width: number): string {
16
+ const truncated = truncateToWidth(line, width);
17
+ return truncated + padding(Math.max(0, width - visibleWidth(truncated)));
18
+ }
19
+
20
+ export function renderSetupOutro(width: number, height: number, elapsedMs: number): string[] {
21
+ const frame = Math.floor(elapsedMs / SETUP_TICK_MS);
22
+ const lines = renderStarfield(width, height, frame + 1000);
23
+ const progress = Math.max(0, Math.min(1, elapsedMs / SETUP_OUTRO_MS));
24
+ const logo = gradientLogo(PI_LOGO, progress * 1.2, { pos: (progress * 2) % 1, strength: 1 - progress });
25
+ const title = theme.bold(theme.fg("success", `${theme.status.success} Setup saved`));
26
+ const subtitle = theme.fg("muted", "Handing off to the normal CLI…");
27
+ const sweepWidth = Math.max(1, Math.min(width - 8, Math.floor((width - 8) * progress)));
28
+ const sweep = `${theme.fg("accent", "━".repeat(sweepWidth))}${theme.fg("dim", "─".repeat(Math.max(0, width - 8 - sweepWidth)))}`;
29
+ const content = [...logo, "", title, subtitle, "", sweep];
30
+ const start = Math.max(0, Math.floor((height - content.length) / 2));
31
+ for (let i = 0; i < content.length && start + i < lines.length; i++) {
32
+ lines[start + i] = centerLine(content[i] ?? "", width);
33
+ }
34
+ return lines.map(line => clampLine(line, width));
35
+ }