@crouton-kit/crouter 0.3.79 → 0.3.80

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.
@@ -23,6 +23,10 @@ export interface ChatViewOptions {
23
23
  /** Theme palette for the activity spinner / dim status / error lines. Absent →
24
24
  * the module defaults (faint + ANSI red) are used. */
25
25
  palette?: AttachPalette;
26
+ /** Pre-colored banner lines pinned as the FIRST child of the chat container,
27
+ * above history (the crouton wordmark). Sits at the very top of the transcript
28
+ * and scrolls up into scrollback as the chat grows. Absent → no banner. */
29
+ banner?: ReadonlyArray<string>;
26
30
  }
27
31
  export declare class ChatView {
28
32
  private readonly tui;
@@ -142,6 +142,16 @@ export class ChatView {
142
142
  this.spinnerStyle = opts.palette?.active ?? defaultSpinnerStyle;
143
143
  this.dimStyle = opts.palette?.muted ?? defaultDimStyle;
144
144
  this.errorStyle = opts.palette?.error ?? defaultErrorStyle;
145
+ // Banner sits above history and OUTSIDE it, so resetChat() (which clears only
146
+ // historyContainer) never wipes it — it persists across every welcome snapshot.
147
+ if (opts.banner && opts.banner.length > 0) {
148
+ const b = new Container();
149
+ b.addChild(new Spacer(1));
150
+ for (const line of opts.banner)
151
+ b.addChild(new Text(line, 1, 0));
152
+ b.addChild(new Spacer(1));
153
+ this.container.addChild(b);
154
+ }
145
155
  this.container.addChild(this.historyContainer);
146
156
  this.container.addChild(this.statusContainer);
147
157
  }
@@ -83,6 +83,13 @@ export declare class InputController {
83
83
  private state;
84
84
  /** Timestamp of the last lone Esc, for double-tap → tree detection (0 = none). */
85
85
  private lastEscapeAt;
86
+ /** Pasted-image registry: placeholder ordinal → temp-file path. The editor
87
+ * shows a clean `[Image #N]` token instead of the ugly temp path; on submit
88
+ * each token is expanded back to its path so the agent reads it off disk.
89
+ * Cleared whenever the editor is emptied (send / clear). */
90
+ private pastedImages;
91
+ /** Monotonic paste counter — the `N` in `[Image #N]`. */
92
+ private pasteSeq;
86
93
  constructor(tui: TUI, editor: CustomEditor, keybindings: KeybindingsManager, hooks: InputControllerHooks);
87
94
  /** Central choke point for every command frame the input layer emits (direct
88
95
  * keybindings, emitDrive, and the slash-command/picker `send` sink). Local
@@ -137,6 +144,13 @@ export declare class InputController {
137
144
  openAuthPicker(build: (controls: PickerControls) => Picker): void;
138
145
  private handleSubmit;
139
146
  private handleFollowUp;
147
+ /** Replace every `[Image #N]` token with the temp-file path of the image the
148
+ * user pasted, so the agent receives a readable path even though the editor
149
+ * only ever showed the clean placeholder. Unknown ordinals are left as-is. */
150
+ private expandImagePlaceholders;
151
+ /** Drop the pasted-image registry and reset the ordinal counter — called once
152
+ * a submit has consumed the editor text. */
153
+ private clearPastedImages;
140
154
  /** Ctrl+G / app.editor.external — open the current editor text in $VISUAL/$EDITOR
141
155
  * and load the saved result back. Ported from pi's `openExternalEditor`: stop
142
156
  * the TUI to release the terminal, run the editor with inherited stdio, and on
@@ -154,5 +168,10 @@ export declare class InputController {
154
168
  * images intact for the user to trim, never a socket-destroying overflow. */
155
169
  private emitDrive;
156
170
  private handlePaste;
171
+ /** Register an image path in the paste registry and splice a clean `[Image #N]`
172
+ * token into the editor at the caret instead of the raw path. On submit the
173
+ * token is expanded back to the path (see expandImagePlaceholders), so the
174
+ * agent reads the image off disk. Shared by clipboard paste and drag-drop. */
175
+ private attachImage;
157
176
  private notify;
158
177
  }
@@ -77,6 +77,13 @@ export class InputController {
77
77
  state;
78
78
  /** Timestamp of the last lone Esc, for double-tap → tree detection (0 = none). */
79
79
  lastEscapeAt = 0;
80
+ /** Pasted-image registry: placeholder ordinal → temp-file path. The editor
81
+ * shows a clean `[Image #N]` token instead of the ugly temp path; on submit
82
+ * each token is expanded back to its path so the agent reads it off disk.
83
+ * Cleared whenever the editor is emptied (send / clear). */
84
+ pastedImages = new Map();
85
+ /** Monotonic paste counter — the `N` in `[Image #N]`. */
86
+ pasteSeq = 0;
80
87
  constructor(tui, editor, keybindings, hooks) {
81
88
  this.tui = tui;
82
89
  this.editor = editor;
@@ -164,6 +171,14 @@ export class InputController {
164
171
  this.editor.onPasteImage = () => {
165
172
  void this.handlePaste();
166
173
  };
174
+ // A file DRAGGED into the pane arrives as bracketed-paste text (its path), not
175
+ // an image event — TitledEditor recognizes an image path and routes it here so
176
+ // it becomes an `[Image #N]` token just like a Ctrl+V paste.
177
+ const dropEditor = this.editor;
178
+ dropEditor.onPasteImagePath = (path) => {
179
+ this.attachImage(path);
180
+ return true;
181
+ };
167
182
  // Keyboard shortcuts that map 1:1 to a frame needing no engine-side data.
168
183
  this.editor.onAction('app.session.new', () => this.emitCommand({ type: 'new_session' }));
169
184
  this.editor.onAction('app.model.cycleForward', () => this.emitCommand({ type: 'cycle_model', direction: 'forward' }));
@@ -385,23 +400,43 @@ export class InputController {
385
400
  // running turn instead of queueing a fresh prompt. `isStreaming` is the
386
401
  // broker snapshot's busy signal; the broker routes `steer` → session.steer()
387
402
  // and `prompt` → session.prompt(). Idle / no state yet → prompt (safe default).
403
+ const expanded = this.expandImagePlaceholders(trimmed);
388
404
  const busy = this.state?.isStreaming === true;
389
405
  const frame = busy
390
- ? { type: 'steer', text: trimmed }
391
- : { type: 'prompt', text: trimmed };
406
+ ? { type: 'steer', text: expanded }
407
+ : { type: 'prompt', text: expanded };
392
408
  if (!this.emitDrive(frame))
393
409
  return; // too large — keep editor to trim
394
410
  this.editor.addToHistory(trimmed);
395
411
  this.editor.setText('');
412
+ this.clearPastedImages();
396
413
  }
397
414
  handleFollowUp() {
398
415
  const text = this.editor.getText().trim();
399
416
  if (!text)
400
417
  return;
401
- if (!this.emitDrive({ type: 'follow_up', text }))
418
+ if (!this.emitDrive({ type: 'follow_up', text: this.expandImagePlaceholders(text) }))
402
419
  return;
403
420
  this.editor.addToHistory(text);
404
421
  this.editor.setText('');
422
+ this.clearPastedImages();
423
+ }
424
+ /** Replace every `[Image #N]` token with the temp-file path of the image the
425
+ * user pasted, so the agent receives a readable path even though the editor
426
+ * only ever showed the clean placeholder. Unknown ordinals are left as-is. */
427
+ expandImagePlaceholders(text) {
428
+ if (this.pastedImages.size === 0)
429
+ return text;
430
+ return text.replace(/\[Image #(\d+)\]/g, (match, digits) => {
431
+ const path = this.pastedImages.get(Number(digits));
432
+ return path ?? match;
433
+ });
434
+ }
435
+ /** Drop the pasted-image registry and reset the ordinal counter — called once
436
+ * a submit has consumed the editor text. */
437
+ clearPastedImages() {
438
+ this.pastedImages.clear();
439
+ this.pasteSeq = 0;
405
440
  }
406
441
  /** Ctrl+G / app.editor.external — open the current editor text in $VISUAL/$EDITOR
407
442
  * and load the saved result back. Ported from pi's `openExternalEditor`: stop
@@ -517,20 +552,29 @@ export class InputController {
517
552
  this.notify(result.note ?? 'Image not pasted');
518
553
  return;
519
554
  }
520
- // Persist to a temp file and splice the PATH into the editor at the caret,
521
- // so the agent reads the image off disk instead of relying on inline base64
522
- // the viewer can't render.
555
+ // Persist to a temp file (the agent reads the image off disk instead of
556
+ // relying on inline base64 the viewer can't render), then attach it as an
557
+ // `[Image #N]` token (see attachImage).
523
558
  const path = writeClipboardImageToFile(result.image);
524
- const cur = this.editor.getText();
525
- const sep = cur && !/\s$/.test(cur) ? ' ' : '';
526
- this.editor.insertTextAtCursor(`${sep}${path} `);
527
- this.notify(result.note ? `Image saved (${result.note}) — path inserted` : 'Image saved — path inserted');
528
- this.tui.requestRender();
559
+ this.attachImage(path, result.note);
529
560
  }
530
561
  catch {
531
562
  this.notify('Could not read the clipboard image');
532
563
  }
533
564
  }
565
+ /** Register an image path in the paste registry and splice a clean `[Image #N]`
566
+ * token into the editor at the caret instead of the raw path. On submit the
567
+ * token is expanded back to the path (see expandImagePlaceholders), so the
568
+ * agent reads the image off disk. Shared by clipboard paste and drag-drop. */
569
+ attachImage(path, note) {
570
+ const n = ++this.pasteSeq;
571
+ this.pastedImages.set(n, path);
572
+ const cur = this.editor.getText();
573
+ const sep = cur && !/\s$/.test(cur) ? ' ' : '';
574
+ this.editor.insertTextAtCursor(`${sep}[Image #${n}] `);
575
+ this.notify(note ? `Image #${n} attached (${note})` : `Image #${n} attached`);
576
+ this.tui.requestRender();
577
+ }
534
578
  notify(message) {
535
579
  this.hooks.onNotice?.(message);
536
580
  }
@@ -26,6 +26,13 @@ export declare function thinkingTitleStyle(level: string | undefined, fallback:
26
26
  * regression test. */
27
27
  export declare function composeTopBorder(width: number, title: string, info: string, titleStyle: (s: string) => string, borderColor: (s: string) => string): string;
28
28
  export declare function outlineCursorLine(line: string): string | undefined;
29
+ /** Recognize a single input chunk as a filesystem path to an image file — how a
30
+ * terminal delivers a file DRAGGED into the pane (the escaped path pasted in
31
+ * bulk, optionally wrapped in bracketed-paste markers). Returns the resolved
32
+ * path, or null when the chunk isn't a lone image path (normal typing, multi-
33
+ * line text, a non-image path, or a path that doesn't point at a real file).
34
+ * Conservative on purpose: it must never transform ordinary typed input. */
35
+ export declare function detectDroppedImagePath(data: string): string | null;
29
36
  export declare class TitledEditor extends CustomEditor {
30
37
  /** crtr's OWN keybindings manager — the same one CustomEditor matches `app.*`
31
38
  * against. We keep a reference so `handleInput` can resolve the newline chord
@@ -43,6 +50,20 @@ export declare class TitledEditor extends CustomEditor {
43
50
  * override, so matching the chord here makes the newline behavior independent
44
51
  * of cross-instance keybinding mirroring. Skipped while the autocomplete popup
45
52
  * is open so Enter-to-confirm keeps working. */
53
+ /** Set by the input controller: called when a bulk input chunk is recognized
54
+ * as a filesystem path to an image file (a file DRAGGED into the pane, which
55
+ * the terminal delivers as bracketed-paste TEXT, not an image event). Returns
56
+ * true when it consumed the drop, so the raw path is never inserted as text. */
57
+ onPasteImagePath?: (path: string) => boolean;
58
+ /** Width of an `[Image #N]` token sitting immediately before the cursor on the
59
+ * current line, or 0 when the cursor isn't at the end of such a token — used to
60
+ * delete/skip the token atomically. The token's separating space is a normal,
61
+ * reachable character, deliberately NOT part of the atom. */
62
+ private imageTokenWidthBeforeCursor;
63
+ /** Width of an `[Image #N]` token sitting immediately after the cursor on the
64
+ * current line, or 0 when the cursor isn't at the start of such a token — used
65
+ * to skip the token with the right arrow. */
66
+ private imageTokenWidthAfterCursor;
46
67
  handleInput(data: string): void;
47
68
  /** Session-name chip painted into the LEFT of the top border. Empty → plain. */
48
69
  title: string;
@@ -5,6 +5,7 @@
5
5
  // 2. a border color that tracks the agent's thinking level (set by attach-cmd
6
6
  // on each state update), mirroring pi's `getThinkingBorderColor`.
7
7
  // Both are pure render-layer chrome; nothing here touches the socket or session.
8
+ import { statSync } from 'node:fs';
8
9
  import { CustomEditor } from '@earendil-works/pi-coding-agent';
9
10
  import { CURSOR_MARKER, truncateToWidth, visibleWidth, } from '@earendil-works/pi-tui';
10
11
  /** The standard thinking levels form a deliberate cool→warm ramp between blue
@@ -152,6 +153,39 @@ export function outlineCursorLine(line) {
152
153
  const replacement = `${MUTED_CURSOR_ON}${grapheme}\x1b[0m`;
153
154
  return line.slice(0, start) + replacement + line.slice(end + SOLID_CURSOR_RESET.length);
154
155
  }
156
+ /** Image file extensions a dragged path is recognized by. */
157
+ const IMAGE_PATH_RE = /\.(png|jpe?g|gif|webp|bmp)$/i;
158
+ /** Recognize a single input chunk as a filesystem path to an image file — how a
159
+ * terminal delivers a file DRAGGED into the pane (the escaped path pasted in
160
+ * bulk, optionally wrapped in bracketed-paste markers). Returns the resolved
161
+ * path, or null when the chunk isn't a lone image path (normal typing, multi-
162
+ * line text, a non-image path, or a path that doesn't point at a real file).
163
+ * Conservative on purpose: it must never transform ordinary typed input. */
164
+ export function detectDroppedImagePath(data) {
165
+ // Unwrap bracketed paste (\x1b[200~ … \x1b[201~) when the terminal uses it.
166
+ const bracketed = data.match(/\x1b\[200~([\s\S]*?)\x1b\[201~/);
167
+ let payload = (bracketed ? bracketed[1] : data).trim();
168
+ if (payload.length < 2)
169
+ return null; // a lone keystroke is never a drop
170
+ if (/[\u0000-\u0008\u000e-\u001f]/.test(payload))
171
+ return null; // control chars → not a path
172
+ // Terminals wrap a dropped path in quotes or backslash-escape its spaces.
173
+ if ((payload.startsWith("'") && payload.endsWith("'")) ||
174
+ (payload.startsWith('"') && payload.endsWith('"'))) {
175
+ payload = payload.slice(1, -1);
176
+ }
177
+ const path = payload.replace(/\\(.)/g, '$1');
178
+ if (/\n/.test(path) || !IMAGE_PATH_RE.test(path))
179
+ return null;
180
+ try {
181
+ if (!statSync(path).isFile())
182
+ return null;
183
+ }
184
+ catch {
185
+ return null; // not a real file → treat as ordinary pasted text
186
+ }
187
+ return path;
188
+ }
155
189
  export class TitledEditor extends CustomEditor {
156
190
  /** crtr's OWN keybindings manager — the same one CustomEditor matches `app.*`
157
191
  * against. We keep a reference so `handleInput` can resolve the newline chord
@@ -175,11 +209,78 @@ export class TitledEditor extends CustomEditor {
175
209
  * override, so matching the chord here makes the newline behavior independent
176
210
  * of cross-instance keybinding mirroring. Skipped while the autocomplete popup
177
211
  * is open so Enter-to-confirm keeps working. */
212
+ /** Set by the input controller: called when a bulk input chunk is recognized
213
+ * as a filesystem path to an image file (a file DRAGGED into the pane, which
214
+ * the terminal delivers as bracketed-paste TEXT, not an image event). Returns
215
+ * true when it consumed the drop, so the raw path is never inserted as text. */
216
+ onPasteImagePath;
217
+ /** Width of an `[Image #N]` token sitting immediately before the cursor on the
218
+ * current line, or 0 when the cursor isn't at the end of such a token — used to
219
+ * delete/skip the token atomically. The token's separating space is a normal,
220
+ * reachable character, deliberately NOT part of the atom. */
221
+ imageTokenWidthBeforeCursor() {
222
+ const { line, col } = this.getCursor();
223
+ const text = this.getLines()[line];
224
+ if (text === undefined)
225
+ return 0;
226
+ const match = text.slice(0, col).match(/\[Image #\d+\]$/);
227
+ return match ? match[0].length : 0;
228
+ }
229
+ /** Width of an `[Image #N]` token sitting immediately after the cursor on the
230
+ * current line, or 0 when the cursor isn't at the start of such a token — used
231
+ * to skip the token with the right arrow. */
232
+ imageTokenWidthAfterCursor() {
233
+ const { line, col } = this.getCursor();
234
+ const text = this.getLines()[line];
235
+ if (text === undefined)
236
+ return 0;
237
+ const match = text.slice(col).match(/^\[Image #\d+\]/);
238
+ return match ? match[0].length : 0;
239
+ }
178
240
  handleInput(data) {
179
241
  if (!this.isShowingAutocomplete() && this.km.matches(data, 'tui.input.newLine')) {
180
242
  this.insertTextAtCursor('\n');
181
243
  return;
182
244
  }
245
+ // Backspace over an `[Image #N]` token deletes the WHOLE token (and its
246
+ // trailing space) in one keypress, not one character at a time — the token is
247
+ // a single atom to the user. We replay the backspace across the token's full
248
+ // width so the base editor's cursor/undo bookkeeping stays correct.
249
+ if (!this.isShowingAutocomplete() && this.km.matches(data, 'tui.editor.deleteCharBackward')) {
250
+ const width = this.imageTokenWidthBeforeCursor();
251
+ if (width > 1) {
252
+ for (let i = 0; i < width; i++)
253
+ super.handleInput(data);
254
+ return;
255
+ }
256
+ }
257
+ // Left/right arrow steps OVER an `[Image #N]` token in one keypress — the
258
+ // token reads as a single character, so the caret never lands inside it. We
259
+ // replay the arrow across the token's full width so cursor bookkeeping stays
260
+ // correct.
261
+ if (!this.isShowingAutocomplete() && this.km.matches(data, 'tui.editor.cursorLeft')) {
262
+ const width = this.imageTokenWidthBeforeCursor();
263
+ if (width > 1) {
264
+ for (let i = 0; i < width; i++)
265
+ super.handleInput(data);
266
+ return;
267
+ }
268
+ }
269
+ if (!this.isShowingAutocomplete() && this.km.matches(data, 'tui.editor.cursorRight')) {
270
+ const width = this.imageTokenWidthAfterCursor();
271
+ if (width > 1) {
272
+ for (let i = 0; i < width; i++)
273
+ super.handleInput(data);
274
+ return;
275
+ }
276
+ }
277
+ // A file dragged into the pane arrives as a bulk paste of its (shell-escaped)
278
+ // path, NOT as an image on the clipboard, so onPasteImage never fires. Detect
279
+ // an image-file path here and hand it to the controller so it becomes an
280
+ // `[Image #N]` token like a clipboard paste, instead of the raw temp path.
281
+ const droppedPath = detectDroppedImagePath(data);
282
+ if (droppedPath && this.onPasteImagePath?.(droppedPath))
283
+ return;
183
284
  super.handleInput(data);
184
285
  }
185
286
  /** Session-name chip painted into the LEFT of the top border. Empty → plain. */
@@ -631,15 +631,12 @@ export async function selectProfileForCwd(cwd, explicitProfile, forcePicker = fa
631
631
  if (pinnedId !== null && pinned === null)
632
632
  clearDefaultProfile(resolvedCwd);
633
633
  // A valid pin decides it outright and STOPS ASKING — the whole point of
634
- // setting a default. Auto-pick it (interactive too), printing a one-line
635
- // breadcrumb so the choice isn't invisible and the user knows how to change
636
- // it. `crtr --pick-profile` (forcePicker) re-opens the menu to re-pin/unpin.
634
+ // setting a default. Auto-pick it silently: the crouton banner the viewer
635
+ // paints a beat later shows the active profile and the `crtr --pick-profile`
636
+ // hint, so a boot-time breadcrumb here would just be redundant.
637
+ // `crtr --pick-profile` (forcePicker) re-opens the menu to re-pin/unpin.
637
638
  if (pinned !== null && !forcePicker) {
638
639
  updateProfileLastUsed(pinned.profileId);
639
- if (isInteractive()) {
640
- process.stdout.write(` ${accent('\u2713')} ${bold(pinned.manifest.name)} ${dim('\u00b7 default for this directory')}` +
641
- ` ${dim('(crtr --pick-profile to change)')}\n`);
642
- }
643
640
  return pinned.profileId;
644
641
  }
645
642
  // Exactly one profile's project dir IS cwd — unambiguous project root, use it
@@ -0,0 +1,13 @@
1
+ export interface BannerInfo {
2
+ /** crtr version, e.g. "0.3.78" → shown as "v0.3.78". */
3
+ version?: string;
4
+ /** Active profile name, e.g. "northlight". */
5
+ profile?: string;
6
+ /** Launch directory (already home-abbreviated if desired). */
7
+ cwd?: string;
8
+ }
9
+ /** The crouton banner as pre-colored terminal lines: the cube on the left with
10
+ * name/version, tagline, profile, and launch path stacked beside rows 1–4.
11
+ * Missing facts are simply omitted (their row shows just the cube). No
12
+ * leading/trailing blank lines — the caller frames it. */
13
+ export declare function croutonBannerLines(info?: BannerInfo): string[];
@@ -0,0 +1,51 @@
1
+ // The crouton welcome banner — a little crouton rendered at the very top of a
2
+ // node's transcript in the attach viewer (the way Claude Code opens with its
3
+ // logo + session facts), with the launch facts stacked to its right: name +
4
+ // version, tagline, profile, and the launch path. It scrolls up into history as
5
+ // the conversation grows. Purely cosmetic.
6
+ //
7
+ // This lives in the VIEWER, not the front-door stdout: the attach TUI takes over
8
+ // the whole terminal, so anything printed to stdout before it starts is wiped.
9
+ // ChatView pins these lines as the first children of its chat container.
10
+ // Clean line-art isometric cube in a single warm crust tone, with a few subtly
11
+ // darker toast flecks. No background fills (they read as detached color slabs in
12
+ // a dark terminal) and no inverted-brightness speckle.
13
+ const RESET = '\u001b[0m';
14
+ const CRUST = '\u001b[38;5;180m'; // warm tan — cube outline + accents
15
+ const FLECK = '\u001b[38;5;137m'; // slightly darker toast dots
16
+ const BOLD = '\u001b[1m';
17
+ const DIM = '\u001b[2m';
18
+ // Darker toast flecks on the cube faces; `.` glyphs recolor a touch darker.
19
+ function crumb(text) {
20
+ return CRUST + text.replace(/\./g, `${FLECK}.${CRUST}`) + RESET;
21
+ }
22
+ // Rows 1–4 all share the same visible width (15 cells), so a fixed 3-space
23
+ // gutter lands every info line in the same column — the stacked-facts layout.
24
+ const CROUTON = [
25
+ crumb(' ________'),
26
+ crumb(' / /|'),
27
+ crumb(' / . . / |'),
28
+ crumb(' /_______/ |'),
29
+ crumb(' | . . | |'),
30
+ crumb(' | . . | /'),
31
+ crumb(' |_______|/'),
32
+ ];
33
+ /** The crouton banner as pre-colored terminal lines: the cube on the left with
34
+ * name/version, tagline, profile, and launch path stacked beside rows 1–4.
35
+ * Missing facts are simply omitted (their row shows just the cube). No
36
+ * leading/trailing blank lines — the caller frames it. */
37
+ export function croutonBannerLines(info = {}) {
38
+ const name = `${BOLD}${CRUST}crouter${RESET}${info.version ? `${DIM} v${info.version}${RESET}` : ''}`;
39
+ const facts = [name, `${DIM}the agentic runtime${RESET}`];
40
+ if (info.profile)
41
+ facts.push(`${DIM}profile: ${RESET}${CRUST}${info.profile}${RESET}`);
42
+ if (info.cwd)
43
+ facts.push(`${DIM}${info.cwd}${RESET}`);
44
+ const out = [];
45
+ for (let i = 0; i < CROUTON.length; i++) {
46
+ // Facts sit beside rows 1..N (the top of the cube), one per row.
47
+ const fact = i >= 1 && i - 1 < facts.length ? ` ${facts[i - 1]}` : '';
48
+ out.push(CROUTON[i] + fact);
49
+ }
50
+ return out;
51
+ }
@@ -72,10 +72,10 @@ export async function bootRoot(opts) {
72
72
  // covering cwd > a synchronous create-or-root-profile prompt (the stable root
73
73
  // profile, headless).
74
74
  const profileId = await selectProfileForCwd(opts.cwd, opts.profile, opts.pickProfile ?? false);
75
- // Immediate feedback: the broker + viewer boot take ~1s and the profile
76
- // selector has just released the terminal a silent cursor reads as a hang.
77
- if (process.stdout.isTTY)
78
- process.stdout.write('\u001b[2m › starting…\u001b[0m\r\n');
75
+ // No boot spinner here: the attach viewer paints the crouton banner the moment
76
+ // the engine attaches (~1s), which is the real "we're up" signal. A separate
77
+ // `› starting…` line would just linger above it in scrollback (the viewer
78
+ // appends, it doesn't clear the screen), so it's redundant.
79
79
  // A born-resident root starts in base mode; it earns the orchestrator persona
80
80
  // the first time it delegates (or on promotion). Resident lifecycle either way.
81
81
  const { launch } = buildLaunchSpec(kind, 'base', { lifecycle: 'resident', hasManager: false, cwd: opts.cwd, profileId });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crouton-kit/crouter",
3
- "version": "0.3.79",
3
+ "version": "0.3.80",
4
4
  "description": "crtr — agent runtime with memory, plugins, and marketplaces",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",