@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.
- package/dist/clients/attach/attach-cmd.js +463 -463
- package/dist/clients/attach/chat-view.d.ts +4 -0
- package/dist/clients/attach/chat-view.js +10 -0
- package/dist/clients/attach/input-controller.d.ts +19 -0
- package/dist/clients/attach/input-controller.js +55 -11
- package/dist/clients/attach/titled-editor.d.ts +21 -0
- package/dist/clients/attach/titled-editor.js +101 -0
- package/dist/core/profiles/select.js +4 -7
- package/dist/core/runtime/banner.d.ts +13 -0
- package/dist/core/runtime/banner.js +51 -0
- package/dist/core/runtime/spawn.js +4 -4
- package/package.json +1 -1
|
@@ -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:
|
|
391
|
-
: { type: 'prompt', text:
|
|
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
|
|
521
|
-
//
|
|
522
|
-
//
|
|
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
|
-
|
|
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
|
|
635
|
-
//
|
|
636
|
-
//
|
|
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
|
-
//
|
|
76
|
-
//
|
|
77
|
-
|
|
78
|
-
|
|
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 });
|