@groeponline/pi-wishcraft 1.4.0 → 1.6.0

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 (64) hide show
  1. package/AGENTS.md +28 -0
  2. package/CHANGELOG.md +25 -0
  3. package/README.md +22 -18
  4. package/ROADMAP.md +3 -3
  5. package/STRATEGY.md +54 -0
  6. package/bash-mode/editor-input.ts +3 -1
  7. package/bash-mode/editor.ts +44 -7
  8. package/bash-mode/forward.ts +64 -0
  9. package/bash-mode/pty-session.ts +356 -0
  10. package/bash-mode/ptyshell-managed.ts +145 -0
  11. package/bash-mode/session-factory.ts +64 -0
  12. package/bash-mode/transcript-v2.ts +77 -0
  13. package/bash-mode/transcript.ts +18 -0
  14. package/bash-mode/types.ts +27 -0
  15. package/docs/bash-mode.md +4 -0
  16. package/docs/configuration.md +45 -1
  17. package/docs/index.md +4 -1
  18. package/package.json +1 -1
  19. package/src/config/effective.ts +122 -0
  20. package/src/config/settings-registry.ts +2 -0
  21. package/src/config/structural-presets.ts +1 -1
  22. package/src/extension/commands/bash-mode-actions.ts +11 -10
  23. package/src/extension/commands/commands.ts +8 -0
  24. package/src/extension/core/state.ts +0 -1
  25. package/src/extension/core/types.ts +2 -2
  26. package/src/extension/session/session-lifecycle.ts +21 -0
  27. package/src/extension/skills/skill-templates.ts +12 -3
  28. package/src/extension/ui/custom-editor.ts +4 -0
  29. package/src/extension/ui/status-line-renderers.ts +6 -7
  30. package/src/extension/welcome/welcome-integration.ts +23 -1
  31. package/src/motion/frames.ts +19 -9
  32. package/src/render/layout.ts +95 -0
  33. package/src/render/motion-rail.ts +79 -0
  34. package/src/render/paint.ts +40 -0
  35. package/src/render/v2-adapter.ts +68 -0
  36. package/src/render/v2-entry.ts +132 -0
  37. package/src/segments/pipeline.ts +164 -0
  38. package/src/segments/types.ts +36 -0
  39. package/src/segments/usage.ts +42 -3
  40. package/src/studio/actions.ts +105 -0
  41. package/src/studio/advice-pane.ts +73 -0
  42. package/src/studio/advise/context.ts +89 -0
  43. package/src/studio/advise/engine.ts +117 -0
  44. package/src/studio/advise/prompts.ts +68 -0
  45. package/src/studio/component.ts +105 -0
  46. package/src/studio/deepwiki/cache.ts +74 -0
  47. package/src/studio/deepwiki/client.ts +73 -0
  48. package/src/studio/deepwiki/extract.ts +66 -0
  49. package/src/studio/inspect.ts +38 -0
  50. package/src/studio/list.ts +48 -0
  51. package/src/studio/open.ts +44 -0
  52. package/src/studio/state.ts +100 -0
  53. package/src/studio/types.ts +30 -0
  54. package/src/welcome/banner.ts +6 -0
  55. package/src/welcome/index.ts +7 -0
  56. package/src/welcome/overlay.ts +6 -0
  57. package/src/welcome/renderer.ts +6 -39
  58. package/src/welcome/types.ts +6 -0
  59. package/src/welcome/welcome-art.ts +148 -0
  60. package/src/working-vibes/frames.ts +25 -0
  61. package/src/working-vibes/index.ts +2 -0
  62. package/src/working-vibes/storage.ts +11 -0
  63. package/bash-mode/shell-session.ts +0 -294
  64. package/src/signal/render.ts +0 -221
@@ -64,6 +64,24 @@ export interface ShellSessionState {
64
64
  lastExitCode: number | null;
65
65
  }
66
66
 
67
+ /**
68
+ * Common surface for the PTY-backed managed session; kept as a seam so the
69
+ * editor/actions wiring does not depend on PtyManagedShellSession internals.
70
+ * writeStdin is optional-callable via the seam (v2 exposes it).
71
+ */
72
+ export interface ManagedShellSessionLike {
73
+ readonly state: ShellSessionState;
74
+ ensureReady(): Promise<void>;
75
+ runCommand(command: string): Promise<void>;
76
+ interrupt(): void;
77
+ dispose(): void;
78
+ writeStdin?(data: string): void;
79
+ /** Whether the session supports editor forward-mode (v2 PTY only). */
80
+ supportsForwardMode(): boolean;
81
+ }
82
+
83
+ export { createForwardState, handleForwardInput, type ForwardState, type ForwardDecision, type PtyAction } from "./forward.ts";
84
+
67
85
  export interface BashModeEditorOptions {
68
86
  keybindings: KeybindingsManager;
69
87
  isBashModeActive: () => boolean;
@@ -73,6 +91,15 @@ export interface BashModeEditorOptions {
73
91
  onEditorSubmit?: () => void;
74
92
  editorBoundaryShortcuts?: EditorBoundaryShortcuts;
75
93
  onInterrupt: () => void;
94
+ /** Forward raw input to a running shell (v2 PTY forward-mode, U4). */
95
+ onForwardInput?: (data: string) => void;
96
+ /**
97
+ * Enable forward-mode: while a command runs, printable input routes to
98
+ * the PTY stdin instead of the editor (AE1). v1 run-blocked behavior is
99
+ * the default until a session opts in. Evaluated per keystroke so the
100
+ * session kind (v1 vs v2) decides at input time.
101
+ */
102
+ forwardWhileRunning?: () => boolean;
76
103
  onNotify: (message: string, level?: "info" | "warning" | "error") => void;
77
104
  getHistoryEntries: (prefix: string) => string[];
78
105
  resolveGhostSuggestion: (
package/docs/bash-mode.md CHANGED
@@ -20,6 +20,10 @@ While bash mode is active:
20
20
 
21
21
  The managed shell is persistent for the current pi session. Command output appears in a transcript below the editor, and shell cwd changes are reflected in the footer path and `shell_mode` segment.
22
22
 
23
+ ## Execution (v2)
24
+
25
+ Commands run under a real PTY via `script(1)` (no native dependency), so programs that read stdin work: printable input typed while a command runs is forwarded to the process, and `ctrl+c` interrupts it. SGR color survives into the transcript when the terminal supports it; `NO_COLOR` renders plain text. When `script(1)` is missing, each command degrades to plain pipe execution with a one-time warning (no color, no interactive stdin).
26
+
23
27
  ## Shell ghost suggestions
24
28
 
25
29
  Bash mode is ghost-first. Successful per-project shell history is the primary source, while deterministic path and git continuations can still extend an existing command. Shell-native completion probes are disabled so `!command` predictions never spawn interactive shell completion subprocesses.
@@ -190,6 +190,37 @@ Make `open_ports` probe a named SSH host instead of the laptop:
190
190
 
191
191
  See [Segments & theming](./segments.md) for the probe's best-effort behavior and requirements.
192
192
 
193
+ ## Bash Mode performance and workflow
194
+
195
+ Bash Mode keeps a project-scoped command history, supports Bash/Zsh/Fish history recall, Git-aware completion, path completion, file drops, ghost suggestions, and safe `!`/`!!` commands. Ghost suggestions are coalesced during rapid typing and cancelled when the editor context changes, so completion never blocks command entry.
196
+
197
+ Use `ctrl+shift+b` to toggle Bash Mode. `Escape` exits the mode, `Tab` accepts one completion token, `Right Arrow` accepts the full ghost suggestion, and the configured editor-boundary shortcuts jump to the start/end of the input. Commands are not submitted while another shell command is running.
198
+
199
+ ## Working indicator
200
+
201
+ The agent's working indicator can use a compact animated frame style. Each style has a distinct visual grammar: `dots` is a quiet three-dot cadence, `pulse` is a centered heartbeat, `bar` is a travelling progress sweep, and `ascii` is a terminal-safe bracketed spinner. Accessibility settings can collapse all four to a static frame.
202
+
203
+ The indicator is intentionally separate from the structural appearance motion: it describes immediate agent activity, while appearance motion describes Signal/deck transitions and ambient state. Choose one of the following depending on the desired feel:
204
+
205
+ | Style | Character | Best for |
206
+ |---|---|---|
207
+ | `dots` | gentle sequential dots | unobtrusive everyday use |
208
+ | `pulse` | expanding/contracting center | clear activity at a glance |
209
+ | `bar` | travelling block across a track | high-information dashboards |
210
+ | `ascii` | portable `[=]`, `[-]`, `[.]` frames | SSH, basic terminals, and logs |
211
+
212
+ Configure it under `wishcraft.workingIndicatorStyle`; supported values are `dots` (default), `pulse`, `bar`, and `ascii`. Motion accessibility settings still apply: reduced/off motion uses a static frame.
213
+
214
+ ```json
215
+ {
216
+ "wishcraft": {
217
+ "workingIndicatorStyle": "pulse"
218
+ }
219
+ }
220
+ ```
221
+
222
+ The Skills route in the Deck and `/skills` manager share one catalog. Use `/skills doctor` to inspect frontmatter, descriptions, duplicate names, warnings, and usage; `/skills new [template]` creates a safe starter skill. Skill deletion is restricted to canonical skill roots.
223
+
193
224
  ## Appearance
194
225
 
195
226
  `powerline.appearance` is independent of the information layout (`powerline.preset`). The structural base paints Signal colors and motion. Layout presets (`default`, `minimal`, `compact`, `full`, `nerd`, `ascii`, `chef`) keep their segment lists until you change `preset`.
@@ -205,7 +236,20 @@ See [Segments & theming](./segments.md) for the probe's best-effort behavior and
205
236
  }
206
237
  ```
207
238
 
208
- Bases: `lanternwake`, `threadbound`, `scryglass`, `runebloom`, `moonwell`, `hexforge`, `vellum`, `wisp`, `starweave`, `crucible`. Apply from Deck → Appearance → Enter, `/wishcraft settings`, or `/signal hexforge` (a structural layout name also writes `appearance.base`). Optional mix keys: `palette`, `signalLayout`, `chrome`, `glyphs`, `deck`, `welcome`, `motion`.
239
+ Bases: `lanternwake`, `threadbound`, `scryglass`, `runebloom`, `moonwell`, `hexforge`, `vellum`, `wisp`, `starweave`, `crucible`. They are deliberately different rather than aliases: each has its own palette, chrome geometry, Signal lane layout, glyph vocabulary, welcome treatment, and signature motion event. Apply from Deck → Appearance → Enter, `/wishcraft settings`, or `/signal hexforge` (a structural layout name also writes `appearance.base`). Optional mix keys: `palette`, `signalLayout`, `chrome`, `glyphs`, `deck`, `welcome`, `motion`.
240
+
241
+ Quick personality guide:
242
+
243
+ - `lanternwake`: warm ember glow and relay motion; welcoming and expressive.
244
+ - `threadbound`: woven separators and linked transitions; structured and calm.
245
+ - `scryglass`: cool observatory palette; sparse, analytical readout.
246
+ - `runebloom`: ornamented glyphs and bloom transitions; mystical and decorative.
247
+ - `moonwell`: blue night palette with slow tidal motion; quiet long sessions.
248
+ - `hexforge`: hard-edged blocks and tool-centric motion; dense engineering mode.
249
+ - `vellum`: restrained paper-like contrast; documentation and review work.
250
+ - `wisp`: lightweight, airy signal; lowest visual weight while remaining animated.
251
+ - `starweave`: constellation accents and travelling signal; exploratory sessions.
252
+ - `crucible`: high-contrast heat and decisive transitions; incident/debug mode.
209
253
 
210
254
  Until `appearance` is set, Signal keeps the layout preset colors. If `preset` itself is a structural name and `appearance` is empty, that name is treated as the base.
211
255
 
package/docs/index.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # pi-wishcraft documentation
2
2
 
3
- The README is the public landing page (`banner.png` only). Everything below lives here.
3
+ This package is a Pi.dev extension, not a standalone website. The README is the short installation and capability contract; the complete operator, configuration, and design references live here.
4
4
 
5
5
  ## Guides
6
6
 
@@ -14,6 +14,9 @@ The README is the public landing page (`banner.png` only). Everything below live
14
14
 
15
15
  ## Design System & vNext Specifications
16
16
 
17
+ These documents describe the terminal-native extension UI and are useful when contributing to or reviewing the package; they are not a web application specification.
18
+
19
+
17
20
  - [vNext Stacked PR Release Plan](./design/vnext-release-plan.md) — The comprehensive implementation roadmap covering PR0 through PR8.
18
21
  - [vNext Architecture & Overview](./design/vnext-overview.md) — Product vision, five pillars, and architectural boundaries.
19
22
  - [Semantic Tokens & Theme Contract](./design/theme-contract.md) — `WishcraftTokens` schema and backward compatibility mappings.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@groeponline/pi-wishcraft",
3
- "version": "1.4.0",
3
+ "version": "1.6.0",
4
4
  "description": "Operator cockpit for Pi: live powerline status, searchable skills, idea queue, sticky Bash, hooks, policy controls, and session UX.",
5
5
  "type": "module",
6
6
  "files": [
@@ -0,0 +1,122 @@
1
+ /**
2
+ * Effective config resolver (U1, KTD4/5).
3
+ *
4
+ * Treats `SETTINGS_REGISTRY` as the single source of truth: every value the
5
+ * caller reads is a registered key, every unknown key is ignored, and every
6
+ * invalid value falls back to the registered default with a per-path error
7
+ * entry. The previous `parsePowerlineConfig` is a v1 consumer; new code reads
8
+ * through this resolver so powerline + skills + harness settings all live in
9
+ * one shape. Strangler: v1 still works until U11 cuts over.
10
+ */
11
+
12
+ import {
13
+ getSettingDefinition,
14
+ SETTING_DEFAULTS,
15
+ SETTINGS_REGISTRY,
16
+ type SettingDefinition,
17
+ type SettingValue,
18
+ } from "./settings-registry.ts";
19
+
20
+ export type SettingPath = keyof typeof SETTING_DEFAULTS;
21
+
22
+ export type SettingInput = Record<string, unknown>;
23
+
24
+ export interface SettingError {
25
+ path: string;
26
+ message: string;
27
+ rawValue: unknown;
28
+ }
29
+
30
+ export interface EffectiveConfig {
31
+ values: Record<SettingPath, SettingValue>;
32
+ errors: SettingError[];
33
+ }
34
+
35
+ export const SETTING_PATHS: readonly SettingPath[] = Object.keys(
36
+ SETTING_DEFAULTS,
37
+ ) as SettingPath[];
38
+
39
+ function coerceToggle(value: unknown): boolean | null {
40
+ if (typeof value === "boolean") return value;
41
+ if (value === "on" || value === "true" || value === 1) return true;
42
+ if (value === "off" || value === "false" || value === 0) return false;
43
+ return null;
44
+ }
45
+
46
+ function coerceNumber(value: unknown): number | null {
47
+ if (typeof value === "number" && Number.isFinite(value)) return value;
48
+ if (typeof value === "string") {
49
+ const trimmed = value.trim();
50
+ if (trimmed === "") return null;
51
+ // Full-string conversion: "1000ms" and friends must not silently
52
+ // coerce to a number; invalid values fall back to the default.
53
+ const parsed = Number(trimmed);
54
+ if (Number.isFinite(parsed)) return parsed;
55
+ }
56
+ return null;
57
+ }
58
+
59
+ function coerceText(value: unknown): string | null {
60
+ return typeof value === "string" ? value : null;
61
+ }
62
+
63
+ function coerceSelect(value: unknown, choices: readonly string[]): string | null {
64
+ if (typeof value !== "string") return null;
65
+ return choices.includes(value) ? value : null;
66
+ }
67
+
68
+ function coerce(definition: SettingDefinition, value: unknown): SettingValue | null {
69
+ if (definition.kind === "toggle") {
70
+ const coerced = coerceToggle(value);
71
+ return coerced;
72
+ }
73
+ if (definition.kind === "number") {
74
+ return coerceNumber(value);
75
+ }
76
+ if (definition.kind === "text") {
77
+ return coerceText(value);
78
+ }
79
+ return coerceSelect(value, definition.choices);
80
+ }
81
+
82
+ function defaultFor(definition: SettingDefinition): SettingValue | null {
83
+ if (definition.defaultValue !== undefined) return definition.defaultValue;
84
+ if (definition.kind === "toggle") return false;
85
+ if (definition.kind === "number") return 0;
86
+ if (definition.kind === "text") return "";
87
+ return "";
88
+ }
89
+
90
+ export function resolveEffectiveConfig(input: SettingInput = {}): EffectiveConfig {
91
+ const values = {} as Record<SettingPath, SettingValue>;
92
+ const errors: SettingError[] = [];
93
+
94
+ for (const path of SETTING_PATHS) {
95
+ const definition = getSettingDefinition(path);
96
+ if (!definition) continue;
97
+ const raw = input[path];
98
+ if (raw === undefined) {
99
+ const fallback = defaultFor(definition);
100
+ if (fallback !== null) values[path] = fallback;
101
+ continue;
102
+ }
103
+ const coerced = coerce(definition, raw);
104
+ if (coerced === null) {
105
+ const fallback = defaultFor(definition);
106
+ if (fallback !== null) values[path] = fallback;
107
+ errors.push({
108
+ path,
109
+ message: `Invalid value for ${path}; using default.`,
110
+ rawValue: raw,
111
+ });
112
+ continue;
113
+ }
114
+ values[path] = coerced;
115
+ }
116
+
117
+ return { values, errors };
118
+ }
119
+
120
+ export function knownSettings(): readonly SettingDefinition[] {
121
+ return SETTINGS_REGISTRY;
122
+ }
@@ -41,6 +41,7 @@ export const SETTING_DEFAULTS = {
41
41
  "wishcraft.readHints": true,
42
42
  "wishcraft.hooksEnabled": true,
43
43
  "wishcraft.repairsEnabled": true,
44
+ "wishcraft.workingIndicatorStyle": "dots",
44
45
  } as const satisfies Record<string, SettingValue>;
45
46
 
46
47
  export const SETTING_GROUPS = [
@@ -80,6 +81,7 @@ export const SETTINGS_REGISTRY = [
80
81
  { id: "status.tps.label", path: "powerline.segmentLabels.tps", group: "status", label: "TPS label", kind: "text", hint: "empty = no label" },
81
82
  { id: "welcome.enabled", path: "powerline.welcome", group: "vibes", label: "Welcome overlay", kind: "toggle", defaultValue: SETTING_DEFAULTS["powerline.welcome"], hint: "on = overlay at startup, off = no welcome" },
82
83
  { id: "welcome.lanternMotion", path: "wishcraft.welcome.animateLantern", group: "vibes", label: "Animate wishcraft lantern", kind: "toggle", hint: "flicker on the lantern" },
84
+ { id: "vibes.indicatorStyle", path: "wishcraft.workingIndicatorStyle", group: "vibes", label: "Working indicator", kind: "select", choices: ["dots", "pulse", "bar", "ascii"], defaultValue: SETTING_DEFAULTS["wishcraft.workingIndicatorStyle"], hint: "frame style used while the agent is working" },
83
85
  { id: "skills.inline", path: "wishcraft.inlineSkills", group: "skills", label: "Inline expand /command and $skill", kind: "toggle", hint: "needs a restart to take effect", restartRequired: true },
84
86
  { id: "skills.readHints", path: "wishcraft.readHints", group: "skills", label: "Read hints", kind: "toggle", defaultValue: SETTING_DEFAULTS["wishcraft.readHints"], hint: "off = no continuation hint after partial reads" },
85
87
  { id: "harness.hooks", path: "wishcraft.hooksEnabled", group: "harness", label: "Hooks enabled", kind: "toggle", defaultValue: SETTING_DEFAULTS["wishcraft.hooksEnabled"], hint: "gate for configured preToolUse / postToolUse / sessionStart hooks" },
@@ -279,7 +279,7 @@ export const STRUCTURAL_PRESETS: Record<StructuralPresetName, StructuralPresetDe
279
279
  signal: {
280
280
  layout: "arc",
281
281
  separators: { left: "───", right: "───", subLeft: "◜", subRight: "◟" },
282
- caps: { leftOpen: "◜", leftClose: "◝", rightOpen: "◞", rightClose: "◟" },
282
+ caps: { leftOpen: "", leftClose: "", rightOpen: "", rightClose: "" },
283
283
  animation: "lunar-breathe",
284
284
  },
285
285
  motion: motion("lunar-breathe"),
@@ -4,7 +4,7 @@ import {
4
4
  readGlobalShellHistory,
5
5
  readProjectHistory,
6
6
  } from "../../../bash-mode/history.ts";
7
- import { ManagedShellSession } from "../../../bash-mode/shell-session.ts";
7
+ import { createShellSession } from "../../../bash-mode/session-factory.ts";
8
8
  import { requestStatusRender } from "../core/segment-context.ts";
9
9
  import type { RuntimeState } from "../core/types.ts";
10
10
 
@@ -34,17 +34,18 @@ export const getShellHistoryEntries = (
34
34
 
35
35
  export const ensureShellSession = async (
36
36
  rt: RuntimeState,
37
- ): Promise<ManagedShellSession> => {
37
+ ): Promise<NonNullable<RuntimeState["shellSession"]>> => {
38
38
  if (!rt.shellSession) {
39
- rt.shellSession = new ManagedShellSession(
40
- getShellPath(),
41
- rt.currentCtx?.cwd ?? process.cwd(),
42
- rt.bashTranscript,
43
- () => requestStatusRender(rt),
44
- (command, cwd) =>
39
+ rt.shellSession = createShellSession({
40
+ shellPath: getShellPath(),
41
+ cwd: rt.currentCtx?.cwd ?? process.cwd(),
42
+ transcript: rt.bashTranscript,
43
+ prefer: "auto",
44
+ onStateChange: () => requestStatusRender(rt),
45
+ onCommandSuccess: (command, cwd) =>
45
46
  appendProjectHistory(rt.currentCtx?.cwd ?? process.cwd(), command, cwd),
46
- rt.bashModeSettings.initScript,
47
- );
47
+ initScript: rt.bashModeSettings.initScript,
48
+ });
48
49
  }
49
50
  await rt.shellSession.ensureReady();
50
51
  return rt.shellSession;
@@ -23,6 +23,7 @@ import { showPowerlineClassicMenu } from "../ui/powerline-menu-view.ts";
23
23
  import { syncAppearanceForLayoutPreset } from "../settings/appearance-write.ts";
24
24
  import { openStashHistory } from "../shortcuts/shortcuts-router.ts";
25
25
  import { ensureShellSession, setBashModeActive } from "./bash-mode-actions.ts";
26
+ import { openSkillStudio } from "../../studio/open.ts";
26
27
  import { setupCustomEditor } from "../ui/custom-editor.ts";
27
28
  import { getPromptHistoryState } from "../history/prompt-history.ts";
28
29
  import {
@@ -213,6 +214,13 @@ export function registerCommands(pi: ExtensionAPI, rt: RuntimeState): void {
213
214
  },
214
215
  });
215
216
 
217
+ pi.registerCommand("studio", {
218
+ description: "Open the Skill Studio fullscreen workspace",
219
+ handler: async (_args, ctx) => {
220
+ await openSkillStudio(rt, ctx);
221
+ },
222
+ });
223
+
216
224
  pi.registerCommand("bash-reset", {
217
225
  description: "Reset the managed bash session",
218
226
  handler: async (_args, ctx) => {
@@ -4,7 +4,6 @@ import { SETTING_DEFAULTS } from "../../config/settings-registry.ts";
4
4
  import { PRESETS } from "../../config/presets.ts";
5
5
  import { BashTranscriptStore } from "../../../bash-mode/transcript.ts";
6
6
  import { BashCompletionEngine } from "../../../bash-mode/completion.ts";
7
- import { ManagedShellSession } from "../../../bash-mode/shell-session.ts";
8
7
  import { PowerlineQueueStore } from "../../../queue/store.ts";
9
8
  import {
10
9
  SessionBranchCache,
@@ -3,7 +3,7 @@ import type { ReadonlyFooterDataProvider } from "@earendil-works/pi-coding-agent
3
3
  import type { BashModeSettings } from "../../../bash-mode/types.ts";
4
4
  import type { BashTranscriptStore } from "../../../bash-mode/transcript.ts";
5
5
  import type { BashCompletionEngine } from "../../../bash-mode/completion.ts";
6
- import type { ManagedShellSession } from "../../../bash-mode/shell-session.ts";
6
+ import type { ManagedShellSessionLike } from "../../../bash-mode/types.ts";
7
7
  import type { PowerlineQueueStore } from "../../../queue/store.ts";
8
8
  import type {
9
9
  SessionAssistantUsage,
@@ -74,7 +74,7 @@ export interface RuntimeState {
74
74
  bashModeActive: boolean;
75
75
  bashTranscript: BashTranscriptStore;
76
76
  bashCompletionEngine: BashCompletionEngine;
77
- shellSession: ManagedShellSession | null;
77
+ shellSession: ManagedShellSessionLike | null;
78
78
  queueStore: PowerlineQueueStore;
79
79
  powerlineCompacting: boolean;
80
80
  deliverAfterRetrySettles: boolean;
@@ -349,6 +349,27 @@ export function registerSessionLifecycle(
349
349
  requestQueueRender(rt);
350
350
  });
351
351
 
352
+ // pi 0.84.0+ emits `session_compact_failed` (not `session_compact`) when
353
+ // auto-compaction or /compact fails or is aborted. Without this handler
354
+ // `powerlineCompacting` stays true and the bar keeps showing the compact
355
+ // rail forever, even though the agent is idle. Root cause of the stuck
356
+ // "COMPACTING" seen on 2026-08-27.
357
+ pi.on("session_compact_failed", async (event, ctx) => {
358
+ rt.powerlineCompacting = false;
359
+ rt.deliverAfterRetrySettles = false;
360
+ rt.currentCtx = ctx;
361
+ rt.coreContextUsageCache.reset();
362
+ requestImmediateStatusRender(rt, { deferDuringTyping: false });
363
+ dispatchSignalEvent(
364
+ rt,
365
+ config.appearance,
366
+ event.errorMessage ? "error" : "idle",
367
+ event.errorMessage ? "compact failed" : undefined,
368
+ );
369
+ finishFailedCompaction(rt, ctx, event.errorMessage ?? "Compaction cancelled");
370
+ requestQueueRender(rt);
371
+ });
372
+
352
373
  pi.on("agent_settled", async (_event, ctx) => {
353
374
  if (rt.powerlineCompacting) {
354
375
  finishFailedCompaction(rt, ctx, "Compaction did not complete");
@@ -3,7 +3,7 @@
3
3
  * Writes `~/.pi/agent/skills/<name>/SKILL.md` then opens $EDITOR.
4
4
  */
5
5
 
6
- import { existsSync, mkdirSync, writeFileSync } from "node:fs";
6
+ import { existsSync, mkdirSync, renameSync, writeFileSync } from "node:fs";
7
7
  import { join } from "node:path";
8
8
  import type { SelectItem } from "@earendil-works/pi-tui";
9
9
 
@@ -177,15 +177,24 @@ export function writeSkillFromTemplate(
177
177
  name: string,
178
178
  template: SkillTemplateId,
179
179
  skillsRoot: string = getAgentPath("skills"),
180
+ options: { overwrite?: boolean } = {},
180
181
  ): { filePath: string } {
181
182
  const safe = sanitizeSkillName(name);
182
183
  const dir = join(skillsRoot, safe);
183
184
  const filePath = join(dir, "SKILL.md");
184
- if (existsSync(filePath)) {
185
+ if (!options.overwrite && existsSync(filePath)) {
185
186
  throw new SkillNameError(`Skill already exists: ${safe}`);
186
187
  }
187
188
  mkdirSync(dir, { recursive: true });
188
- writeFileSync(filePath, renderSkillTemplate(template, safe), "utf8");
189
+ if (options.overwrite && existsSync(filePath)) {
190
+ // Write the replacement next to the original and atomically rename it
191
+ // over SKILL.md; a failed write leaves the existing skill untouched.
192
+ const tmp = join(dir, `.SKILL.md.${process.pid}.tmp`);
193
+ writeFileSync(tmp, renderSkillTemplate(template, safe), "utf8");
194
+ renameSync(tmp, filePath);
195
+ } else {
196
+ writeFileSync(filePath, renderSkillTemplate(template, safe), "utf8");
197
+ }
189
198
  invalidateSkillCache();
190
199
  return { filePath };
191
200
  }
@@ -132,6 +132,10 @@ export function setupCustomEditor(
132
132
  rt.shellSession?.interrupt();
133
133
  ctx.ui.notify("Sent interrupt to shell", "info");
134
134
  },
135
+ onForwardInput: (data) => {
136
+ rt.shellSession?.writeStdin?.(data);
137
+ },
138
+ forwardWhileRunning: () => rt.shellSession?.supportsForwardMode() ?? false,
135
139
  onNotify: (message, level = "info") => ctx.ui.notify(message, level),
136
140
  getHistoryEntries: (prefix) => getShellHistoryEntries(rt, prefix),
137
141
  resolveGhostSuggestion: async (text, signal) => {
@@ -21,7 +21,7 @@ import {
21
21
  import { config } from "../core/state.ts";
22
22
  import type { RuntimeState } from "../core/types.ts";
23
23
  import { prefersAsciiGlyphs } from "../../motion/index.ts";
24
- import { renderSignal } from "../../signal/render.ts";
24
+ import { renderStatusLineV2 } from "../../render/v2-entry.ts";
25
25
 
26
26
  /**
27
27
  * Get cached responsive layout or compute fresh one.
@@ -77,7 +77,7 @@ export function getResponsiveLayout(
77
77
  const appearance = resolveAppearanceMix(
78
78
  effectiveAppearanceMix(config.appearance, config.preset),
79
79
  );
80
- rt.lastLayoutResult = renderSignal(
80
+ rt.lastLayoutResult = renderStatusLineV2(
81
81
  segmentCtx,
82
82
  presetDef,
83
83
  rt.signal,
@@ -182,7 +182,7 @@ export function renderBashTranscriptLines(
182
182
  ): string[] {
183
183
  if (!rt.bashModeActive) return [];
184
184
 
185
- const snapshot = rt.bashTranscript.getSnapshot();
185
+ const snapshot = rt.bashTranscript.recentCommands(4);
186
186
  if (snapshot.commands.length === 0) return [];
187
187
 
188
188
  const lines: string[] = [];
@@ -192,7 +192,7 @@ export function renderBashTranscriptLines(
192
192
  );
193
193
  }
194
194
 
195
- const recentCommands = snapshot.commands.slice(-4);
195
+ const recentCommands = snapshot.commands;
196
196
  for (const command of recentCommands) {
197
197
  const promptGlyph =
198
198
  (rt.shellSession?.state.shellName ?? "shell") === "fish" ? ">" : "$";
@@ -211,9 +211,8 @@ export function renderBashTranscriptLines(
211
211
  ` ${theme.fg("accent", promptGlyph)} ${commandLine} ${theme.fg("dim", "(")}${status}${theme.fg("dim", ")")}`,
212
212
  );
213
213
 
214
- const outputTail = command.output.slice(-6);
215
- for (const outputLine of outputTail) {
216
- lines.push(
214
+ // recentCommands already tails output to the store's outputTail (6).
215
+ for (const outputLine of command.output) { lines.push(
217
216
  ` ${truncateToWidth(outputLine, Math.max(1, width - 3), "…")}`,
218
217
  );
219
218
  }
@@ -4,13 +4,31 @@ import {
4
4
  discoverLoadedCounts,
5
5
  discoverWhatsNew,
6
6
  getRecentSessions,
7
+ normalizeWelcomeArt,
8
+ type WelcomeArtTheme,
7
9
  } from "../../welcome/index.ts";
8
10
  import { estimateInitialContextTokens } from "../../usage/context.ts";
9
- import { isRecord } from "../settings/settings-io.ts";
11
+ import { isRecord, readSettings } from "../settings/settings-io.ts";
10
12
  import type { RuntimeState } from "../core/types.ts";
11
13
  import { getQueueContext } from "../queue/queue-context.ts";
12
14
  import { pickNextReviewIdea } from "../queue/idea-review.ts";
13
15
 
16
+ interface WelcomeArtSettings {
17
+ art: WelcomeArtTheme;
18
+ animate: boolean;
19
+ }
20
+
21
+ /** Read `wishcraft.welcome.{art,animateLantern}` from settings.json. */
22
+ function readWelcomeArtSettings(ctx: any): WelcomeArtSettings {
23
+ const settings = readSettings(ctx.cwd ?? process.cwd());
24
+ const wishcraft = isRecord(settings.wishcraft) ? settings.wishcraft : {};
25
+ const welcome = isRecord(wishcraft.welcome) ? wishcraft.welcome : {};
26
+ return {
27
+ art: normalizeWelcomeArt(welcome.art),
28
+ animate: welcome.animateLantern === true,
29
+ };
30
+ }
31
+
14
32
  export function setupWelcomeHeader(rt: RuntimeState, ctx: any) {
15
33
  const modelName = ctx.model?.name || ctx.model?.id || "No model";
16
34
  const providerName = ctx.model?.provider || "Unknown";
@@ -37,6 +55,8 @@ export function setupWelcomeHeader(rt: RuntimeState, ctx: any) {
37
55
  whatsNew,
38
56
  nextIdeaText,
39
57
  );
58
+ const artSettings = readWelcomeArtSettings(ctx);
59
+ header.setArt(artSettings.art, artSettings.animate);
40
60
  rt.welcomeHeaderActive = true;
41
61
 
42
62
  ctx.ui.setHeader(() => {
@@ -115,6 +135,8 @@ export function setupWelcomeOverlay(rt: RuntimeState, ctx: any) {
115
135
  whatsNew,
116
136
  nextIdeaText,
117
137
  );
138
+ const artSettings = readWelcomeArtSettings(ctx);
139
+ welcome.setArt(artSettings.art, artSettings.animate);
118
140
 
119
141
  let countdown = 30;
120
142
  let dismissed = false;
@@ -16,8 +16,10 @@ const GEOMETRY_FRAMES: Record<string, string[]> = {
16
16
  ember: ["◇", "◈", "◆", "◈"],
17
17
  orbit: ["◜", "◝", "◞", "◟"],
18
18
  bloom: ["·", "◇", "◈", "◆", "◈", "◇"],
19
- heat: ["░", "▒", "▓", "█", "▓", "▒"],
20
- liquid: ["·", "░", "▒", "▓", "█"],
19
+ // Density-pulse families (box-drawing, één glyphsysteem — shade-blokken
20
+ // zijn bewust weg: drie families door elkaar las als modder).
21
+ heat: ["─", "╌", "╾", "━", "╾", "╌"],
22
+ liquid: ["·", "╌", "╾", "━"],
21
23
  stitch: ["·", "╼", "◆", "╾"],
22
24
  refract: ["╭", "╮", "╯", "╰"],
23
25
  write: ["─", "──", "───", "────╾"],
@@ -73,12 +75,20 @@ export function sweepPosition(
73
75
  return direction === "reverse" ? width - 1 - step : step;
74
76
  }
75
77
 
76
- /** Trail glyph by distance from the travelling head. */
78
+ /**
79
+ * Trail glyph by distance behind the travelling head. One family:
80
+ * box-drawing density fading back to the light track — no shade blocks.
81
+ */
77
82
  export function trailGlyph(distance: number, ascii = false): string {
78
- if (ascii) return distance === 0 ? "*" : "-";
79
- if (distance === 0) return "█";
80
- if (distance === 1) return "▓";
81
- if (distance === 2) return "▒";
82
- if (distance === 3) return "░";
83
- return "━";
83
+ if (ascii) {
84
+ if (distance === 0) return "*";
85
+ if (distance === 1) return "=";
86
+ if (distance === 2) return ">";
87
+ return "-";
88
+ }
89
+ if (distance === 0) return "●";
90
+ if (distance === 1) return "━";
91
+ if (distance === 2) return "╾";
92
+ if (distance === 3) return "╌";
93
+ return "─";
84
94
  }