@groeponline/pi-wishcraft 1.4.16 → 1.4.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,17 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [1.4.17] - 2026-09-07
6
+
7
+ ### Added
8
+ - Bash forward-mode privacy notice (issue #71): the first keystroke forwarded to a running command raises an info notice that typed input may echo into the transcript; documented in `docs/bash-mode.md`.
9
+ - Managed PTY suite is now script-gated (issue #73): PTY-core tests skip when `script(1)` is missing instead of silently passing in degraded pipe mode, the basic run asserts a real PTY transport, and the explicit pipe-mode tests still cover the fallback.
10
+
11
+ ### Fixed
12
+ - Bash paste-while-running (issue #72): a bracketed paste performed while a command runs is stripped of its markers and forwarded to the child stdin — including split delivery across input chunks — instead of silently queuing in the editor buffer behind the command.
13
+ - PTY/transcript memory bounds (from jan `fix/runtime-pty-bounds`): a single unterminated output line is capped at a 64 KiB UTF-8 tail with a one-time notice, the partial-escape tail is bounded, and the active transcript command trims head lines/bytes within limits instead of growing unbounded.
14
+ - Segment-options spread hygiene (CodeFactor #86–89): `normalizeSegmentOptions` assigns fields imperatively instead of spreading conditional empty objects.
15
+
5
16
  ## [1.4.16] - 2026-09-05
6
17
 
7
18
  ### Fixed
@@ -31,6 +31,24 @@ import type {
31
31
  GhostSuggestion,
32
32
  } from "./types.ts";
33
33
 
34
+ const BRACKETED_PASTE_START = "\x1b[200~";
35
+ const BRACKETED_PASTE_END = "\x1b[201~";
36
+
37
+ function splitTrailingPasteMarkerPrefix(value: string): [string, string] {
38
+ let tail = "";
39
+ for (const marker of [BRACKETED_PASTE_START, BRACKETED_PASTE_END]) {
40
+ const max = Math.min(marker.length - 1, value.length);
41
+ for (let length = max; length > tail.length; length -= 1) {
42
+ const candidate = value.slice(-length);
43
+ if (marker.startsWith(candidate)) {
44
+ tail = candidate;
45
+ break;
46
+ }
47
+ }
48
+ }
49
+ return tail ? [value.slice(0, -tail.length), tail] : [value, ""];
50
+ }
51
+
34
52
  export class BashModeEditor extends CustomEditor {
35
53
  private readonly keybindingsRef: KeybindingsManager;
36
54
  private readonly optionsRef: BashModeEditorOptions;
@@ -43,6 +61,10 @@ export class BashModeEditor extends CustomEditor {
43
61
  private ghostAbort: AbortController | null = null;
44
62
  private ghostToken = 0;
45
63
  private ghostSchedule: ReturnType<typeof setTimeout> | null = null;
64
+ /** A bracketed paste was split across input chunks while forwarding. */
65
+ private forwardPasteOpen = false;
66
+ /** Trailing prefix of a bracketed-paste delimiter awaiting the next input chunk. */
67
+ private forwardPasteMarkerTail = "";
46
68
 
47
69
  constructor(
48
70
  tui: any,
@@ -112,6 +134,25 @@ export class BashModeEditor extends CustomEditor {
112
134
  }
113
135
 
114
136
  handleInput(data: string): void {
137
+ // v2 forward-mode gate, shared by the paste branch (issue #72) and the
138
+ // per-keystroke forward further down: while a command runs, input
139
+ // belongs to the child stdin, never to the editor buffer behind it.
140
+ const forwardActive =
141
+ this.optionsRef.isBashModeActive() &&
142
+ this.optionsRef.isShellRunning() &&
143
+ (this.optionsRef.forwardWhileRunning?.() ?? false) &&
144
+ this.optionsRef.onForwardInput != null;
145
+ if (!forwardActive) {
146
+ this.forwardPasteOpen = false;
147
+ this.forwardPasteMarkerTail = "";
148
+ } else {
149
+ data = this.forwardPasteMarkerTail + data;
150
+ const [complete, tail] = splitTrailingPasteMarkerPrefix(data);
151
+ data = complete;
152
+ this.forwardPasteMarkerTail = tail;
153
+ if (!data) return;
154
+ }
155
+
115
156
  const droppedPathText = droppedPathTextFromInput(data);
116
157
  if (droppedPathText !== null) {
117
158
  this.insertTextAtCursor(droppedPathText);
@@ -125,8 +166,23 @@ export class BashModeEditor extends CustomEditor {
125
166
  }
126
167
 
127
168
  const pasteInProgress =
128
- data.includes("\x1b[200~") || Reflect.get(this, "isInPaste") === true;
169
+ data.includes(BRACKETED_PASTE_START) ||
170
+ data.includes(BRACKETED_PASTE_END) ||
171
+ Reflect.get(this, "isInPaste") === true ||
172
+ (forwardActive && this.forwardPasteOpen);
129
173
  if (pasteInProgress) {
174
+ if (forwardActive) {
175
+ const startIndex = data.lastIndexOf(BRACKETED_PASTE_START);
176
+ const endIndex = data.lastIndexOf(BRACKETED_PASTE_END);
177
+ if (startIndex !== -1 || endIndex !== -1) {
178
+ this.forwardPasteOpen = startIndex > endIndex;
179
+ }
180
+ const payload = data
181
+ .replaceAll(BRACKETED_PASTE_START, "")
182
+ .replaceAll(BRACKETED_PASTE_END, "");
183
+ if (payload) this.optionsRef.onForwardInput?.(payload);
184
+ return;
185
+ }
130
186
  super.handleInput(data);
131
187
  if (Reflect.get(this, "isInPaste") === true) {
132
188
  return;
@@ -169,15 +225,14 @@ export class BashModeEditor extends CustomEditor {
169
225
  // (\x04) must also forward — line-oriented stdin programs (read,
170
226
  // sudo, git rebase -i) cannot proceed without a line terminator.
171
227
  // Opt-in via forwardWhileRunning so v1 run-blocked behavior unchanged.
228
+ // (forwardActive is computed above; bracketed pastes take the paste
229
+ // branch, which forwards the stripped payload too.)
172
230
  if (
173
- bashMode &&
174
- this.optionsRef.isShellRunning() &&
175
- (this.optionsRef.forwardWhileRunning?.() ?? false) &&
176
- this.optionsRef.onForwardInput != null &&
231
+ forwardActive &&
177
232
  !isKeyRelease(data) &&
178
233
  (isPrintableInput(data) || data === "\r" || data === "\n" || data === "\x04")
179
234
  ) {
180
- this.optionsRef.onForwardInput(data);
235
+ this.optionsRef.onForwardInput?.(data);
181
236
  return;
182
237
  }
183
238
 
@@ -22,6 +22,38 @@ import { basename, join } from "node:path";
22
22
  import { randomBytes } from "node:crypto";
23
23
  import { spawn, spawnSync, type ChildProcessWithoutNullStreams } from "node:child_process";
24
24
 
25
+ const MAX_PENDING_LINE_BYTES = 64 * 1024;
26
+ const MAX_ESCAPE_TAIL_CHARS = 4096;
27
+ const TRUNCATED_LINE_NOTICE = "[wishcraft] output line truncated; keeping tail";
28
+
29
+ /**
30
+ * Restricts a string to its trailing UTF-8 bytes without splitting a multibyte character.
31
+ *
32
+ * @param value - The string to restrict
33
+ * @param maxBytes - The maximum number of UTF-8 bytes to retain
34
+ * @returns The UTF-8-safe trailing portion of `value`
35
+ */
36
+ function utf8Tail(value: string, maxBytes: number): string {
37
+ if (maxBytes <= 0) return "";
38
+ const bytes = Buffer.from(value, "utf8");
39
+ if (bytes.length <= maxBytes) return value;
40
+ let start = bytes.length - maxBytes;
41
+ while (start < bytes.length && (bytes[start]! & 0xc0) === 0x80) start += 1;
42
+ return bytes.subarray(start).toString("utf8");
43
+ }
44
+
45
+ /**
46
+ * Limits a partial terminal escape sequence to the configured character bound while preserving its introducer.
47
+ *
48
+ * @param value - The partial escape sequence to limit
49
+ * @returns The original value or a bounded value with its escape introducer preserved
50
+ */
51
+ function boundedEscapeTail(value: string): string {
52
+ if (value.length <= MAX_ESCAPE_TAIL_CHARS) return value;
53
+ const prefixLength = value.startsWith("\x1b") && value.length > 1 ? 2 : 1;
54
+ return value.slice(0, prefixLength) + value.slice(-(MAX_ESCAPE_TAIL_CHARS - prefixLength));
55
+ }
56
+
25
57
  /**
26
58
  * Fresh per-command completion delimiter. The wrapper prints it after the
27
59
  * sourced script ends; because it is unpredictable, a command cannot forge
@@ -121,6 +153,8 @@ interface RunningCommand {
121
153
  escapeTail: string;
122
154
  /** Wrapper result observed on stdout; publication waits for child close. */
123
155
  pendingResult: PtyRunResult | null;
156
+ /** Emit at most one notice when a single output line exceeds the pending cap. */
157
+ lineTruncated: boolean;
124
158
  resolve: (result: PtyRunResult) => void;
125
159
  settled: boolean;
126
160
  }
@@ -182,6 +216,7 @@ export class PtyShellSession {
182
216
  buffer: "",
183
217
  escapeTail: "",
184
218
  pendingResult: null,
219
+ lineTruncated: false,
185
220
  resolve: (result) => {
186
221
  if (running.settled) return;
187
222
  running.settled = true;
@@ -308,8 +343,9 @@ export class PtyShellSession {
308
343
  let work = merged;
309
344
  const partial = /(?:\x1b(?:\[[0-9;?]*[ -/]*|\][^\x07\x1b]*|[P^_][^\x07\x1b]*))$|\x1b$/.exec(work);
310
345
  if (partial) {
311
- running.escapeTail = partial[0];
312
- work = work.slice(0, work.length - running.escapeTail.length);
346
+ const rawTail = partial[0];
347
+ running.escapeTail = boundedEscapeTail(rawTail);
348
+ work = work.slice(0, work.length - rawTail.length);
313
349
  }
314
350
 
315
351
  const color = this.color && this.state.mode === "pty";
@@ -317,7 +353,13 @@ export class PtyShellSession {
317
353
 
318
354
  running.buffer += filtered;
319
355
  const parts = running.buffer.split("\n");
320
- running.buffer = parts.pop() ?? "";
356
+ const remainder = parts.pop() ?? "";
357
+ const boundedRemainder = utf8Tail(remainder, MAX_PENDING_LINE_BYTES);
358
+ if (boundedRemainder !== remainder && !running.lineTruncated) {
359
+ running.lineTruncated = true;
360
+ this.onOutput(TRUNCATED_LINE_NOTICE);
361
+ }
362
+ running.buffer = boundedRemainder;
321
363
 
322
364
  for (const rawLine of parts) {
323
365
  const line = rawLine.replace(/\r$/, "");
@@ -4,6 +4,29 @@ function byteLength(value: string): number {
4
4
  return Buffer.byteLength(value, "utf8");
5
5
  }
6
6
 
7
+ /**
8
+ * Extracts the end of a string within a UTF-8 byte limit.
9
+ *
10
+ * @param value - The source string
11
+ * @param maxBytes - The maximum number of UTF-8 bytes to include
12
+ * @returns The UTF-8-safe tail of `value`
13
+ */
14
+ function utf8Tail(value: string, maxBytes: number): string {
15
+ if (maxBytes <= 0) return "";
16
+ const bytes = Buffer.from(value, "utf8");
17
+ if (bytes.length <= maxBytes) return value;
18
+
19
+ let start = bytes.length - maxBytes;
20
+ while (start < bytes.length && (bytes[start]! & 0xc0) === 0x80) start += 1;
21
+ return bytes.subarray(start).toString("utf8");
22
+ }
23
+
24
+ /**
25
+ * Normalizes lines by removing carriage returns and splitting embedded newline characters.
26
+ *
27
+ * @param lines - The lines to normalize
28
+ * @returns The normalized lines
29
+ */
7
30
  function compactLines(lines: string[]): string[] {
8
31
  const normalized: string[] = [];
9
32
  for (const line of lines) {
@@ -119,8 +142,50 @@ export class BashTranscriptStore {
119
142
  this.commandIndex.delete(removed.id);
120
143
  this.totalLines = Math.max(0, this.totalLines - removed.output.length);
121
144
  this.totalBytes = Math.max(0, this.totalBytes - removed.outputBytes);
122
- this.truncatedCommands += 1;
123
- removed.truncated = true;
145
+ this.markTruncated(removed);
146
+ }
147
+
148
+ // A single active command used to bypass both global limits because there
149
+ // was no older command to evict. Trim that command from the head instead:
150
+ // bash mode is a tail-oriented live view, so the newest output is the
151
+ // useful part and memory remains bounded even for multi-megabyte logs.
152
+ const oldest = this.commands[0];
153
+ if (!oldest) return;
154
+
155
+ while (oldest.output.length > 0 && this.totalLines > this.settings.transcriptMaxLines) {
156
+ this.dropOldestLine(oldest);
157
+ }
158
+ while (oldest.output.length > 1 && this.totalBytes > this.settings.transcriptMaxBytes) {
159
+ this.dropOldestLine(oldest);
124
160
  }
161
+
162
+ if (oldest.output.length === 1 && this.totalBytes > this.settings.transcriptMaxBytes) {
163
+ const previous = oldest.output[0]!;
164
+ const previousBytes = byteLength(previous) + 1;
165
+ const bytesOutsideLine = Math.max(0, this.totalBytes - previousBytes);
166
+ const lineBudget = Math.max(0, this.settings.transcriptMaxBytes - bytesOutsideLine - 1);
167
+ const tail = utf8Tail(previous, lineBudget);
168
+ const tailBytes = byteLength(tail) + 1;
169
+ oldest.output[0] = tail;
170
+ oldest.outputBytes = Math.max(0, oldest.outputBytes - previousBytes + tailBytes);
171
+ this.totalBytes = Math.max(0, this.totalBytes - previousBytes + tailBytes);
172
+ this.markTruncated(oldest);
173
+ }
174
+ }
175
+
176
+ private dropOldestLine(command: BashCommandRecord): void {
177
+ const removed = command.output.shift();
178
+ if (removed === undefined) return;
179
+ const removedBytes = byteLength(removed) + 1;
180
+ command.outputBytes = Math.max(0, command.outputBytes - removedBytes);
181
+ this.totalLines = Math.max(0, this.totalLines - 1);
182
+ this.totalBytes = Math.max(0, this.totalBytes - removedBytes);
183
+ this.markTruncated(command);
184
+ }
185
+
186
+ private markTruncated(command: BashCommandRecord): void {
187
+ if (command.truncated) return;
188
+ command.truncated = true;
189
+ this.truncatedCommands += 1;
125
190
  }
126
191
  }
package/docs/bash-mode.md CHANGED
@@ -24,6 +24,8 @@ The managed shell is persistent for the current pi session. Command output appea
24
24
 
25
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
26
 
27
+ Privacy: forwarded keystrokes echo back through the PTY into the transcript (issue #71), so typing into a password-style prompt persists it in the transcript store. The first forward per run raises an info notice; avoid secrets at interactive prompts or clear the transcript afterwards. Pasted text while a command runs goes to the child stdin too (issue #72) — it never queues silently in the editor.
28
+
27
29
  ## Shell ghost suggestions
28
30
 
29
31
  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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@groeponline/pi-wishcraft",
3
- "version": "1.4.16",
3
+ "version": "1.4.17",
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": [
@@ -2,132 +2,144 @@ import { normalizeCostCurrency } from "../usage/rates.ts";
2
2
  import { isRecord } from "./primitives.ts";
3
3
  import type { StatusLineSegmentOptions } from "./types.ts";
4
4
 
5
+ /**
6
+ * Normalizes raw segment configuration into supported status-line segment options.
7
+ *
8
+ * Invalid values are ignored, numeric limits are normalized, string values are trimmed,
9
+ * and non-empty template overrides are preserved for supported option groups.
10
+ *
11
+ * @param raw - Raw segment configuration to normalize
12
+ * @returns The validated and normalized segment options
13
+ */
5
14
  export function normalizeSegmentOptions(
6
15
  raw: Record<string, unknown>,
7
16
  ): StatusLineSegmentOptions {
8
17
  const options: StatusLineSegmentOptions = {};
9
18
 
10
19
  if (isRecord(raw.model)) {
11
- options.model = {
12
- ...(typeof raw.model.showThinkingLevel === "boolean"
13
- ? { showThinkingLevel: raw.model.showThinkingLevel }
14
- : {}),
15
- ...(raw.model.display === "name" || raw.model.display === "qualified"
16
- ? { display: raw.model.display }
17
- : {}),
18
- };
20
+ const model: NonNullable<StatusLineSegmentOptions["model"]> = {};
21
+ if (typeof raw.model.showThinkingLevel === "boolean") {
22
+ model.showThinkingLevel = raw.model.showThinkingLevel;
23
+ }
24
+ if (raw.model.display === "name" || raw.model.display === "qualified") {
25
+ model.display = raw.model.display;
26
+ }
27
+ options.model = model;
19
28
  }
20
29
 
21
30
  if (isRecord(raw.path)) {
22
- options.path = {
23
- ...(raw.path.mode === "basename" ||
31
+ const path: NonNullable<StatusLineSegmentOptions["path"]> = {};
32
+ if (
33
+ raw.path.mode === "basename" ||
24
34
  raw.path.mode === "abbreviated" ||
25
35
  raw.path.mode === "full"
26
- ? { mode: raw.path.mode }
27
- : {}),
28
- ...(typeof raw.path.maxLength === "number" &&
36
+ ) {
37
+ path.mode = raw.path.mode;
38
+ }
39
+ if (
40
+ typeof raw.path.maxLength === "number" &&
29
41
  Number.isFinite(raw.path.maxLength) &&
30
42
  raw.path.maxLength > 0
31
- ? { maxLength: Math.floor(raw.path.maxLength) }
32
- : {}),
33
- };
43
+ ) {
44
+ path.maxLength = Math.floor(raw.path.maxLength);
45
+ }
46
+ options.path = path;
34
47
  }
35
48
 
36
49
  if (isRecord(raw.git)) {
37
- options.git = {
38
- ...(typeof raw.git.showBranch === "boolean"
39
- ? { showBranch: raw.git.showBranch }
40
- : {}),
41
- ...(typeof raw.git.showStaged === "boolean"
42
- ? { showStaged: raw.git.showStaged }
43
- : {}),
44
- ...(typeof raw.git.showUnstaged === "boolean"
45
- ? { showUnstaged: raw.git.showUnstaged }
46
- : {}),
47
- ...(typeof raw.git.showUntracked === "boolean"
48
- ? { showUntracked: raw.git.showUntracked }
49
- : {}),
50
- ...(raw.git.polling === "full" ||
50
+ const git: NonNullable<StatusLineSegmentOptions["git"]> = {};
51
+ if (typeof raw.git.showBranch === "boolean") git.showBranch = raw.git.showBranch;
52
+ if (typeof raw.git.showStaged === "boolean") git.showStaged = raw.git.showStaged;
53
+ if (typeof raw.git.showUnstaged === "boolean") {
54
+ git.showUnstaged = raw.git.showUnstaged;
55
+ }
56
+ if (typeof raw.git.showUntracked === "boolean") {
57
+ git.showUntracked = raw.git.showUntracked;
58
+ }
59
+ if (
60
+ raw.git.polling === "full" ||
51
61
  raw.git.polling === "branch" ||
52
62
  raw.git.polling === "off"
53
- ? { polling: raw.git.polling }
54
- : {}),
55
- ...(typeof raw.git.hostIcon === "boolean"
56
- ? { hostIcon: raw.git.hostIcon }
57
- : {}),
58
- ...(typeof raw.git.showAheadBehind === "boolean"
59
- ? { showAheadBehind: raw.git.showAheadBehind }
60
- : {}),
61
- ...(typeof raw.git.showCommit === "boolean"
62
- ? { showCommit: raw.git.showCommit }
63
- : {}),
64
- ...(typeof raw.git.maxCommitSubjectLength === "number" &&
63
+ ) {
64
+ git.polling = raw.git.polling;
65
+ }
66
+ if (typeof raw.git.hostIcon === "boolean") git.hostIcon = raw.git.hostIcon;
67
+ if (typeof raw.git.showAheadBehind === "boolean") {
68
+ git.showAheadBehind = raw.git.showAheadBehind;
69
+ }
70
+ if (typeof raw.git.showCommit === "boolean") git.showCommit = raw.git.showCommit;
71
+ if (
72
+ typeof raw.git.maxCommitSubjectLength === "number" &&
65
73
  Number.isFinite(raw.git.maxCommitSubjectLength) &&
66
74
  raw.git.maxCommitSubjectLength > 0
67
- ? { maxCommitSubjectLength: Math.floor(raw.git.maxCommitSubjectLength) }
68
- : {}),
69
- };
75
+ ) {
76
+ git.maxCommitSubjectLength = Math.floor(raw.git.maxCommitSubjectLength);
77
+ }
78
+ options.git = git;
70
79
  }
71
80
 
72
81
  if (isRecord(raw.time)) {
73
- options.time = {
74
- ...(raw.time.format === "12h" || raw.time.format === "24h"
75
- ? { format: raw.time.format }
76
- : {}),
77
- ...(typeof raw.time.showSeconds === "boolean"
78
- ? { showSeconds: raw.time.showSeconds }
79
- : {}),
80
- };
82
+ const time: NonNullable<StatusLineSegmentOptions["time"]> = {};
83
+ if (raw.time.format === "12h" || raw.time.format === "24h") {
84
+ time.format = raw.time.format;
85
+ }
86
+ if (typeof raw.time.showSeconds === "boolean") {
87
+ time.showSeconds = raw.time.showSeconds;
88
+ }
89
+ options.time = time;
81
90
  }
82
91
 
83
92
  if (isRecord(raw.cost)) {
84
93
  const currency = normalizeCostCurrency(raw.cost.currency);
85
- options.cost = {
86
- ...(raw.cost.subscriptionDisplay === "subscription" ||
94
+ const cost: NonNullable<StatusLineSegmentOptions["cost"]> = {};
95
+ if (
96
+ raw.cost.subscriptionDisplay === "subscription" ||
87
97
  raw.cost.subscriptionDisplay === "reported-cost" ||
88
98
  raw.cost.subscriptionDisplay === "both"
89
- ? { subscriptionDisplay: raw.cost.subscriptionDisplay }
90
- : {}),
91
- ...(currency ? { currency } : {}),
92
- };
99
+ ) {
100
+ cost.subscriptionDisplay = raw.cost.subscriptionDisplay;
101
+ }
102
+ if (currency) cost.currency = currency;
103
+ options.cost = cost;
93
104
  }
94
105
 
95
106
  if (isRecord(raw.context)) {
96
- options.context = {
97
- ...(raw.context.format === "full" || raw.context.format === "percent"
98
- ? { format: raw.context.format }
99
- : {}),
100
- };
107
+ const context: NonNullable<StatusLineSegmentOptions["context"]> = {};
108
+ if (raw.context.format === "full" || raw.context.format === "percent") {
109
+ context.format = raw.context.format;
110
+ }
111
+ options.context = context;
101
112
  }
102
113
 
103
114
  if (isRecord(raw.cache_read)) {
104
- options.cache_read = {
105
- ...(raw.cache_read.format === "tokens" ||
115
+ const cacheRead: NonNullable<StatusLineSegmentOptions["cache_read"]> = {};
116
+ if (
117
+ raw.cache_read.format === "tokens" ||
106
118
  raw.cache_read.format === "percent" ||
107
119
  raw.cache_read.format === "both"
108
- ? { format: raw.cache_read.format }
109
- : {}),
110
- };
120
+ ) {
121
+ cacheRead.format = raw.cache_read.format;
122
+ }
123
+ options.cache_read = cacheRead;
111
124
  }
112
125
 
113
126
  if (isRecord(raw.openPorts)) {
114
- options.openPorts = {
115
- ...(typeof raw.openPorts.includeUdp === "boolean"
116
- ? { includeUdp: raw.openPorts.includeUdp }
117
- : {}),
118
- ...(typeof raw.openPorts.host === "string" && raw.openPorts.host.trim()
119
- ? { host: raw.openPorts.host.trim() }
120
- : {}),
121
- };
127
+ const openPorts: NonNullable<StatusLineSegmentOptions["openPorts"]> = {};
128
+ if (typeof raw.openPorts.includeUdp === "boolean") {
129
+ openPorts.includeUdp = raw.openPorts.includeUdp;
130
+ }
131
+ if (typeof raw.openPorts.host === "string" && raw.openPorts.host.trim()) {
132
+ openPorts.host = raw.openPorts.host.trim();
133
+ }
134
+ options.openPorts = openPorts;
122
135
  }
123
136
 
124
137
  if (isRecord(raw.tps)) {
125
- options.tps = {
126
- ...(typeof raw.tps.windowMs === "number" &&
127
- Number.isFinite(raw.tps.windowMs)
128
- ? { windowMs: Math.min(5000, Math.max(500, Math.floor(raw.tps.windowMs))) }
129
- : {}),
130
- };
138
+ const tps: NonNullable<StatusLineSegmentOptions["tps"]> = {};
139
+ if (typeof raw.tps.windowMs === "number" && Number.isFinite(raw.tps.windowMs)) {
140
+ tps.windowMs = Math.min(5000, Math.max(500, Math.floor(raw.tps.windowMs)));
141
+ }
142
+ options.tps = tps;
131
143
  }
132
144
 
133
145
  // Generic `template` override for every segment option group:
@@ -0,0 +1,69 @@
1
+ /**
2
+ * structural-preset-data.ts
3
+ * ---------------------------------------------------------------------------
4
+ * Shared helpers and chrome/deck/welcome constants for the structural presets.
5
+ * The preset definitions live in structural-preset-table.ts; the public API
6
+ * (getStructuralPreset, etc.) stays in structural-presets.ts.
7
+ * ---------------------------------------------------------------------------
8
+ */
9
+
10
+ import type { ChromeSpec, DeckSpec, MotionRef, WelcomeSpec, WishcraftTokens } from "./types.ts";
11
+ import type { MotionEvent } from "../motion/types.ts";
12
+
13
+ export function tokens(partial: Partial<WishcraftTokens>): Partial<WishcraftTokens> {
14
+ return partial;
15
+ }
16
+
17
+ export function motion(
18
+ signature: MotionRef,
19
+ overrides?: Partial<Record<MotionEvent, MotionRef>>,
20
+ ): Partial<Record<MotionEvent, MotionRef>> {
21
+ const base: Partial<Record<MotionEvent, MotionRef>> = {
22
+ idle: "wisp",
23
+ thinking: signature,
24
+ streaming: signature,
25
+ "tool.start": signature,
26
+ "tool.end": "rune-bloom",
27
+ "idea.capture": "rune-bloom",
28
+ "skill.insert": "rune-bloom",
29
+ "policy.deny": "rune-bloom",
30
+ repair: signature,
31
+ compact: "bar",
32
+ success: "rune-bloom",
33
+ warning: "rune-bloom",
34
+ error: "rune-bloom",
35
+ };
36
+ return { ...base, ...overrides };
37
+ }
38
+
39
+ export const ROUNDED_CHROME: ChromeSpec = {
40
+ frame: "rounded",
41
+ corners: { tl: "╭", tr: "╮", bl: "╰", br: "╯" },
42
+ dividers: { horizontal: "─", vertical: "│", cross: "┼" },
43
+ density: "medium",
44
+ };
45
+
46
+ export const MINIMAL_CHROME: ChromeSpec = {
47
+ frame: "minimal",
48
+ corners: { tl: " ", tr: " ", bl: " ", br: " " },
49
+ dividers: { horizontal: "─", vertical: " ", cross: " " },
50
+ density: "spacious",
51
+ };
52
+
53
+ export const BORDERLESS_CHROME: ChromeSpec = {
54
+ frame: "borderless",
55
+ corners: { tl: "", tr: "", bl: "", br: "" },
56
+ dividers: { horizontal: "─", vertical: " ", cross: " " },
57
+ density: "spacious",
58
+ };
59
+
60
+ export const DEFAULT_DECK: DeckSpec = {
61
+ navigation: "tabs",
62
+ panelStyle: "framed",
63
+ activityStyle: "pulse",
64
+ };
65
+
66
+ export const DEFAULT_WELCOME: WelcomeSpec = {
67
+ lantern: true,
68
+ ambient: true,
69
+ };