@crouton-kit/crouter 0.3.79 → 0.3.81

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
  }
@@ -191,7 +201,7 @@ export class ChatView {
191
201
  // renderedPendingTools → pendingTools, where live tool events find them.)
192
202
  if (message.stopReason === 'aborted' || message.stopReason === 'error') {
193
203
  const errorMessage = message.stopReason === 'aborted'
194
- ? 'Operation aborted'
204
+ ? 'Interrupted'
195
205
  : (message.errorMessage ?? 'Error');
196
206
  component.updateResult({ content: [{ type: 'text', text: errorMessage }], isError: true });
197
207
  }
@@ -323,8 +333,12 @@ export class ChatView {
323
333
  if (this.streamingComponent) {
324
334
  const stopReason = event.message.stopReason;
325
335
  let errorMessage = event.message.errorMessage;
326
- if (stopReason === 'aborted' && !errorMessage) {
327
- errorMessage = 'Operation aborted';
336
+ // An aborted turn is a user interrupt (Esc). The provider tends to
337
+ // attach its own generic text ("Request was aborted."), so override it
338
+ // outright — don't gate on the absence of an errorMessage — with a
339
+ // plain, human label.
340
+ if (stopReason === 'aborted') {
341
+ errorMessage = 'Interrupted';
328
342
  // Surface the abort on the assistant bubble itself, mirroring pi
329
343
  // (interactive-mode.js:2280 sets streamingMessage.errorMessage before
330
344
  // updateContent) so the rendered message shows the annotation.
@@ -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
  }
@@ -32,6 +32,7 @@ import { surfaceTmuxStyleArgs } from '../../core/runtime/surface-bg.js';
32
32
  * `/graph` toggles the in-process overlay. Exported so the editor folds these
33
33
  * into the slash autocomplete list. */
34
34
  export const CANVAS_SLASH_COMMANDS = [
35
+ { name: 'clear', description: 'Clear the conversation and free context — a fresh, empty session (like /new)' },
35
36
  { name: 'graph', description: 'Toggle the canvas GRAPH view (your local subscription tree)' },
36
37
  { name: 'promote', description: 'Promote this node to an orchestrator — /promote, or /promote <kind> to specialize' },
37
38
  { name: 'resume-node', description: 'Open the canvas navigator (search/scope/sort/tree) and resume the chosen node' },
@@ -48,7 +49,7 @@ const CANVAS_NAMES = new Set(CANVAS_SLASH_COMMANDS.map((c) => c.name));
48
49
  * local node, `/context` opens local `crtr node inspect context`. `/graph`
49
50
  * (the in-process GRAPH overlay, already remote-safe — see graph-overlay.ts)
50
51
  * and `/view` (a self-contained popup unrelated to `nodeId`) stay available. */
51
- const REMOTE_UNSAFE_CANVAS_NAMES = new Set(['promote', 'resume-node', 'context', 'rename']);
52
+ const REMOTE_UNSAFE_CANVAS_NAMES = new Set(['promote', 'resume-node', 'context', 'rename', 'clear']);
52
53
  /** tmux named colours accepted by `/color` (plus `colourN`/`#rrggbb`, validated
53
54
  * by pattern). Mirrors tmux's colour-name table so a typo is rejected with the
54
55
  * options rather than silently ignored by tmux. */
@@ -279,6 +280,13 @@ function dispatchCanvasCommand(name, arg, ctx) {
279
280
  return true;
280
281
  }
281
282
  switch (name) {
283
+ case 'clear':
284
+ // Claude-Code ergonomics: `/clear` == `/new` — drop the conversation and
285
+ // start an empty session (context freed). The broker's new_session re-
286
+ // emits a `welcome`, whose applySnapshot resetChat()s the pane, so the
287
+ // transcript is wiped too. Aliased here so muscle memory works.
288
+ ctx.send({ type: 'new_session' });
289
+ return true;
282
290
  case 'graph':
283
291
  // `/graph` opens the attach viewer's in-process GRAPH overlay, not a tmux
284
292
  // popup. Dispatch reaches it through the viewer integration's `onGraph` hook.
@@ -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. */
@@ -54,6 +54,13 @@ export declare function clearPid(nodeId: string): void;
54
54
  export declare function listNodes(filter?: {
55
55
  status?: NodeStatus | NodeStatus[];
56
56
  }): NodeRow[];
57
+ /** Resumable ROOT conversations pinned to `cwd`, most-recently-created first.
58
+ * A "root" is the top of a spine (`parent IS NULL`) that is a real
59
+ * conversation (not a `kind:'human'` ask) and still resumable (`active`/`idle`,
60
+ * never a `done`/`dead`/`canceled` node). This backs the front-door
61
+ * `crtr -c`/`-r` resume: cwd is the key (the Claude-Code mental model — "my
62
+ * last session HERE"), so it never teleports across projects. */
63
+ export declare function listResumableRoots(cwd: string): NodeRow[];
57
64
  /** Record `A subscribes_to B` — A receives B's output. active=true wakes A on
58
65
  * emit; passive accumulates pointers without a wake. Mutable; callable by anyone. */
59
66
  export declare function subscribe(subscriber: string, publisher: string, active?: boolean): void;
@@ -288,6 +288,21 @@ export function listNodes(filter) {
288
288
  }
289
289
  return rows.map(rowFrom);
290
290
  }
291
+ /** Resumable ROOT conversations pinned to `cwd`, most-recently-created first.
292
+ * A "root" is the top of a spine (`parent IS NULL`) that is a real
293
+ * conversation (not a `kind:'human'` ask) and still resumable (`active`/`idle`,
294
+ * never a `done`/`dead`/`canceled` node). This backs the front-door
295
+ * `crtr -c`/`-r` resume: cwd is the key (the Claude-Code mental model — "my
296
+ * last session HERE"), so it never teleports across projects. */
297
+ export function listResumableRoots(cwd) {
298
+ const rows = openDb()
299
+ .prepare(`SELECT * FROM nodes
300
+ WHERE parent IS NULL AND kind != 'human'
301
+ AND status IN ('active','idle') AND cwd = ?
302
+ ORDER BY created DESC`)
303
+ .all(cwd);
304
+ return rows.map(rowFrom);
305
+ }
291
306
  // ---------------------------------------------------------------------------
292
307
  // Edges
293
308
  // ---------------------------------------------------------------------------
@@ -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
+ }
@@ -23,6 +23,7 @@ import { join } from 'node:path';
23
23
  import { randomUUID } from 'node:crypto';
24
24
  import { getAgentDir, initTheme, } from '@earendil-works/pi-coding-agent';
25
25
  import { jobDir, nodeDir, sessionListCachePath, viewSocketPath } from '../canvas/paths.js';
26
+ import { missingPackageWarning } from './package-health.js';
26
27
  import { SessionListCache } from './session-list-cache.js';
27
28
  import { getNode, updateNode } from '../canvas/index.js';
28
29
  import { FRONT_DOOR_ENV } from './front-door.js';
@@ -180,6 +181,19 @@ export async function runBroker(nodeId) {
180
181
  catch (err) {
181
182
  process.stderr.write(`[broker] WARNING: initTheme failed: ${String(err)}\n`);
182
183
  }
184
+ // Diagnose a stale local-path pi package (e.g. a moved crouter repo whose
185
+ // relative `pi-crtr-extensions` path in settings.json no longer resolves). pi
186
+ // discards a missing package silently, dropping its whole feature (the Claude
187
+ // command/marketplace bridge) with no trace; log it loudly so broker.log shows
188
+ // the cause. Fix: `crtr sys setup`.
189
+ try {
190
+ const warn = missingPackageWarning(getAgentDir());
191
+ if (warn !== null)
192
+ process.stderr.write(`[broker] WARNING: ${warn}\n`);
193
+ }
194
+ catch {
195
+ /* best-effort */
196
+ }
183
197
  // N3: the node's display name is re-applied inside rebindSession (below) so it
184
198
  // survives a session replacement (new_session/switch_session/fork), not only at
185
199
  // boot.
@@ -3,6 +3,8 @@
3
3
  // crtr → boot a root in this terminal (no prompt)
4
4
  // crtr [dir] → root pinned to dir
5
5
  // crtr [dir] ["prompt"] → root with a starter prompt
6
+ // crtr -c | --continue → resume the MRU root pinned to cwd (no picker)
7
+ // crtr -r | --resume → resume a root pinned to cwd, chosen from a menu
6
8
  // crtr --name NAME ... → named root
7
9
  // crtr --profile <id-or-name> → root under an explicit profile (else the
8
10
  // startup selector: a per-cwd pinned default,
@@ -18,7 +20,10 @@
18
20
  // over the terminal and the process never returns.
19
21
  import { existsSync, statSync } from 'node:fs';
20
22
  import { resolve as resolvePath } from 'node:path';
23
+ import { getAgentDir } from '@earendil-works/pi-coding-agent';
21
24
  import { bootRoot } from './spawn.js';
25
+ import { resumeRoot } from './resume.js';
26
+ import { missingPackageWarning } from './package-health.js';
22
27
  function isDir(p) {
23
28
  try {
24
29
  return existsSync(p) && statSync(p).isDirectory();
@@ -31,7 +36,7 @@ function isDir(p) {
31
36
  * positional dir/prompt). A leading token in this set still boots a root —
32
37
  * without it, `crtr --name X` would fall through to the dispatcher and error as
33
38
  * an unknown subcommand. */
34
- const FRONT_DOOR_FLAGS = new Set(['--name', '--kind', '--profile', '--pick-profile']);
39
+ const FRONT_DOOR_FLAGS = new Set(['--name', '--kind', '--profile', '--pick-profile', '--continue', '-c', '--resume', '-r']);
35
40
  /** Parse `[dir] [prompt]` positionals + the front-door flags out of the leftover
36
41
  * tokens after the bare `crtr`. */
37
42
  function parseRootArgs(tokens) {
@@ -40,6 +45,7 @@ function parseRootArgs(tokens) {
40
45
  let kind;
41
46
  let profile;
42
47
  let pickProfile = false;
48
+ let resume;
43
49
  const positionals = [];
44
50
  for (let i = 0; i < tokens.length; i++) {
45
51
  const t = tokens[i];
@@ -55,6 +61,12 @@ function parseRootArgs(tokens) {
55
61
  else if (t === '--pick-profile') {
56
62
  pickProfile = true;
57
63
  }
64
+ else if (t === '--continue' || t === '-c') {
65
+ resume = 'continue';
66
+ }
67
+ else if (t === '--resume' || t === '-r') {
68
+ resume = 'pick';
69
+ }
58
70
  else if (t.startsWith('--')) {
59
71
  // ignore unknown flags for the front door
60
72
  }
@@ -67,7 +79,7 @@ function parseRootArgs(tokens) {
67
79
  cwd = resolvePath(positionals.shift());
68
80
  }
69
81
  const prompt = positionals.length > 0 ? positionals.join(' ') : undefined;
70
- return { cwd, prompt, name, kind, profile, pickProfile };
82
+ return { cwd, prompt, name, kind, profile, pickProfile, resume };
71
83
  }
72
84
  /** Env marker set on every pi the front door boots. Its presence means we are
73
85
  * already inside a front-door-booted root, so a nested front-door launch must
@@ -113,6 +125,27 @@ export async function maybeBootRoot(root, argv) {
113
125
  // Unambiguous front-door launch → boot a resident root inline (exec pi in
114
126
  // this terminal). Does not return.
115
127
  const args = parseRootArgs(tokens);
128
+ // Surface any stale/missing local-path pi package BEFORE the viewer takes over
129
+ // the terminal — pi discards a missing package silently, so this is the only
130
+ // moment the user sees why (e.g.) their ~/.claude commands vanished.
131
+ try {
132
+ const warn = missingPackageWarning(getAgentDir());
133
+ if (warn !== null)
134
+ process.stderr.write(`\n${warn}\n\n`);
135
+ }
136
+ catch {
137
+ /* best-effort diagnostic — never block a boot */
138
+ }
139
+ // `-c`/`-r`: resume the last root pinned to this cwd (MRU, or via picker) by
140
+ // taking over THIS terminal as its viewer. Falls through to a fresh boot when
141
+ // nothing here is resumable, or when the picker was aborted.
142
+ if (args.resume !== undefined) {
143
+ const outcome = await resumeRoot(args.cwd, args.resume === 'pick');
144
+ // 'empty' → nothing to resume here, fall through to a fresh boot.
145
+ // 'aborted' → the user dismissed the picker; exit without surprise-booting.
146
+ if (outcome === 'aborted')
147
+ return true;
148
+ }
116
149
  await bootRoot({ ...args });
117
150
  return true;
118
151
  }