@crouton-kit/crouter 0.3.78 → 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.
Files changed (80) hide show
  1. package/dist/build-root.d.ts +12 -4
  2. package/dist/build-root.js +25 -6
  3. package/dist/builtin-memory/crouter-development/plugins.md +82 -5
  4. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/__tests__/provider-rotation.test.ts +1228 -12
  5. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.ts +3 -3
  6. package/dist/builtin-pi-packages/pi-crtr-extensions/lib/subscription-state.ts +2 -787
  7. package/dist/clients/attach/__tests__/attach-chrome-remote.test.js +8 -3
  8. package/dist/clients/attach/__tests__/attach-keybindings.test.d.ts +1 -0
  9. package/dist/clients/attach/__tests__/attach-keybindings.test.js +113 -0
  10. package/dist/clients/attach/__tests__/mermaid-render.test.js +9 -1
  11. package/dist/clients/attach/attach-cmd.d.ts +9 -1
  12. package/dist/clients/attach/attach-cmd.js +847 -801
  13. package/dist/clients/attach/auth-pickers.d.ts +0 -12
  14. package/dist/clients/attach/auth-pickers.js +64 -15
  15. package/dist/clients/attach/chat-view.d.ts +4 -0
  16. package/dist/clients/attach/chat-view.js +10 -0
  17. package/dist/clients/attach/graph-overlay.d.ts +12 -2
  18. package/dist/clients/attach/graph-overlay.js +83 -33
  19. package/dist/clients/attach/input-controller.d.ts +19 -0
  20. package/dist/clients/attach/input-controller.js +55 -11
  21. package/dist/clients/attach/titled-editor.d.ts +21 -0
  22. package/dist/clients/attach/titled-editor.js +101 -0
  23. package/dist/commands/human/queue.js +3 -4
  24. package/dist/commands/pkg/plugin-inspect.js +22 -1
  25. package/dist/commands/pkg/plugin-manage.js +31 -9
  26. package/dist/commands/sys/__tests__/setup-core.test.js +158 -1
  27. package/dist/commands/sys/doctor.js +42 -4
  28. package/dist/commands/sys/setup-core.d.ts +37 -0
  29. package/dist/commands/sys/setup-core.js +138 -1
  30. package/dist/commands/sys/setup.d.ts +88 -0
  31. package/dist/commands/sys/setup.js +915 -171
  32. package/dist/commands/view-pick.d.ts +4 -0
  33. package/dist/commands/view-pick.js +17 -7
  34. package/dist/core/__tests__/canvas-inbox-watcher.test.js +34 -9
  35. package/dist/core/__tests__/command-plugins-surfaces.test.d.ts +1 -0
  36. package/dist/core/__tests__/command-plugins-surfaces.test.js +298 -0
  37. package/dist/core/__tests__/command-plugins.test.d.ts +1 -0
  38. package/dist/core/__tests__/command-plugins.test.js +444 -0
  39. package/dist/core/__tests__/fixtures/fake-engine.d.ts +6 -0
  40. package/dist/core/__tests__/fixtures/fake-engine.js +9 -1
  41. package/dist/core/__tests__/preview-registry-sync.test.js +30 -1
  42. package/dist/core/__tests__/scope-crouter-home-fence.test.d.ts +1 -0
  43. package/dist/core/__tests__/scope-crouter-home-fence.test.js +55 -0
  44. package/dist/core/canvas/browse/app.d.ts +6 -0
  45. package/dist/core/canvas/browse/app.js +71 -41
  46. package/dist/core/command-plugins/adapter.d.ts +15 -0
  47. package/dist/core/command-plugins/adapter.js +145 -0
  48. package/dist/core/command-plugins/compose.d.ts +5 -0
  49. package/dist/core/command-plugins/compose.js +56 -0
  50. package/dist/core/command-plugins/discovery.d.ts +104 -0
  51. package/dist/core/command-plugins/discovery.js +565 -0
  52. package/dist/core/config.d.ts +2 -5
  53. package/dist/core/keybindings/__tests__/bespoke-consumers.test.d.ts +1 -0
  54. package/dist/core/keybindings/__tests__/bespoke-consumers.test.js +40 -0
  55. package/dist/core/keybindings/__tests__/resolve.test.js +2 -2
  56. package/dist/core/keybindings/catalog.d.ts +3 -3
  57. package/dist/core/keybindings/catalog.js +2 -1
  58. package/dist/core/profiles/select.d.ts +6 -0
  59. package/dist/core/profiles/select.js +90 -59
  60. package/dist/core/provider-management.d.ts +12 -0
  61. package/dist/core/provider-management.js +24 -0
  62. package/dist/core/runtime/banner.d.ts +13 -0
  63. package/dist/core/runtime/banner.js +51 -0
  64. package/dist/core/runtime/broker.js +7 -6
  65. package/dist/core/runtime/pi-vendored.d.ts +8 -0
  66. package/dist/core/runtime/pi-vendored.js +14 -0
  67. package/dist/core/runtime/recap.d.ts +1 -1
  68. package/dist/core/runtime/recap.js +50 -25
  69. package/dist/core/runtime/session-list-cache.d.ts +10 -0
  70. package/dist/core/runtime/session-list-cache.js +94 -26
  71. package/dist/core/runtime/spawn.js +5 -17
  72. package/dist/core/runtime/tmux.js +2 -1
  73. package/dist/core/scope.js +27 -4
  74. package/dist/core/subscription-state.d.ts +90 -0
  75. package/dist/core/subscription-state.js +762 -0
  76. package/dist/daemon/crtrd.js +253 -12
  77. package/dist/pi-extensions/canvas-recap.js +43 -17
  78. package/dist/types.d.ts +6 -13
  79. package/dist/types.js +0 -3
  80. package/package.json +7 -3
@@ -1,17 +1,5 @@
1
1
  import type { TUI } from '@earendil-works/pi-tui';
2
2
  import type { Picker, PickerControls } from './pickers.js';
3
- /** A compact auth summary for a provider — used by non-picker surfaces (e.g.
4
- * the `crtr sys setup` login tab) to show which providers are already
5
- * configured without mounting the full selector. */
6
- export interface ProviderAuthSummary {
7
- id: string;
8
- name: string;
9
- configured: boolean;
10
- }
11
- /** Summarize which providers have a stored credential in `agentDir`'s auth.json.
12
- * Mirrors the OAuth selector's provider list; `configured` is true when the
13
- * provider has an explicit stored credential (OAuth token or API key). */
14
- export declare function summarizeProviderAuth(agentDir: string): ProviderAuthSummary[];
15
3
  /** `/login` — show the provider selector, then run pi's native login flow in
16
4
  * the viewer. On success, notifies the broker to reload auth via reload_auth.
17
5
  * Returns a Picker (OAuthSelectorComponent as the overlay). */
@@ -10,6 +10,8 @@
10
10
  import { join } from 'node:path';
11
11
  import { execFile } from 'node:child_process';
12
12
  import { AuthStorage, ModelRegistry, OAuthSelectorComponent, LoginDialogComponent, } from '@earendil-works/pi-coding-agent';
13
+ import { loginAnthropic, loginOpenAICodex } from '@earendil-works/pi-ai/oauth';
14
+ import { ANTHROPIC_PROVIDER_ID, commitManagedLogin, isManagedProvider, readSubscriptionPool, removeManagedProvider, } from '../../core/subscription-state.js';
13
15
  /** Build an AuthStorage + ModelRegistry from the broker's agentDir (or pi's
14
16
  * default). Both point at the SAME auth.json the broker uses — the viewer and
15
17
  * broker share the filesystem (tmux-local invariant). */
@@ -47,17 +49,6 @@ function buildProviderList(authStorage, registry) {
47
49
  }
48
50
  return providers;
49
51
  }
50
- /** Summarize which providers have a stored credential in `agentDir`'s auth.json.
51
- * Mirrors the OAuth selector's provider list; `configured` is true when the
52
- * provider has an explicit stored credential (OAuth token or API key). */
53
- export function summarizeProviderAuth(agentDir) {
54
- const { authStorage, registry } = buildLocalAuth(agentDir);
55
- return buildProviderList(authStorage, registry).map((p) => ({
56
- id: p.id,
57
- name: p.name,
58
- configured: authStorage.get(p.id) !== undefined,
59
- }));
60
- }
61
52
  /** Open the browser with a URL (best-effort; platform-specific). */
62
53
  function openBrowser(url) {
63
54
  const cmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open';
@@ -112,6 +103,52 @@ export function buildLoginPicker(tui, agentDir, onAuthReloaded, notify, controls
112
103
  // inline under the editor (same slot as the model picker), not as a centered
113
104
  // overlay. `replace` focuses the dialog; `close()` (via finish) unmounts it.
114
105
  replace(dialog, dialog);
106
+ if (isManagedProvider(providerId)) {
107
+ // Managed provider (anthropic / openai-codex): drive pi's OAuth flow ourselves so we hold
108
+ // the credential, then persist it through the coordinated writer (commitManagedLogin ->
109
+ // commitDefaultIdentity for the default slot) rather than authStorage.login(). That keeps
110
+ // auth.json + the rotation pool identity in lockstep and runs the account-uniqueness gate
111
+ // the raw path skipped. /login here manages only the DEFAULT slot (label === providerId).
112
+ const runLogin = providerId === ANTHROPIC_PROVIDER_ID ? loginAnthropic : loginOpenAICodex;
113
+ // Snapshot the default slot's rate-limit timestamp BEFORE OAuth begins: a strictly newer
114
+ // concurrent 429 recorded during the login must survive a same-account re-login.
115
+ const defaultLastRateLimitedAt = readSubscriptionPool(providerId).find((entry) => entry.label === providerId)?.lastRateLimitedAt ?? 0;
116
+ runLogin({
117
+ onAuth: (info) => {
118
+ dialog.showAuth(info.url, info.instructions);
119
+ openBrowser(info.url);
120
+ },
121
+ onPrompt: (prompt) => dialog.showPrompt(prompt.message, prompt.placeholder),
122
+ onProgress: (message) => {
123
+ dialog.showProgress(message);
124
+ },
125
+ onManualCodeInput: () => dialog.showManualInput('Paste the code or URL:'),
126
+ })
127
+ .then((credential) => {
128
+ if (settled)
129
+ return;
130
+ const accountId = typeof credential.accountId === 'string' && credential.accountId.trim()
131
+ ? credential.accountId.trim()
132
+ : undefined;
133
+ commitManagedLogin(providerId, providerId, {
134
+ refresh: credential.refresh,
135
+ access: credential.access,
136
+ expires: credential.expires,
137
+ accountId,
138
+ defaultLastRateLimitedAt,
139
+ });
140
+ notify(`Logged in to ${provider.name}`);
141
+ onAuthReloaded();
142
+ finish();
143
+ })
144
+ .catch((err) => {
145
+ if (err?.name !== 'AbortError') {
146
+ notify(`Login error: ${String(err?.message ?? err)}`);
147
+ }
148
+ finish();
149
+ });
150
+ return;
151
+ }
115
152
  if (provider.authType === 'oauth') {
116
153
  const isKnownOAuth = authStorage.getOAuthProviders().some((p) => p.id === providerId);
117
154
  if (!isKnownOAuth) {
@@ -195,10 +232,22 @@ export function buildLogoutPicker(agentDir, onAuthReloaded, notify, close) {
195
232
  const getAuthStatus = (id) => authStorage.getAuthStatus(id);
196
233
  const onSelect = (providerId) => {
197
234
  const provider = providers.find((p) => p.id === providerId);
198
- authStorage.logout(providerId);
199
- close();
200
- notify(`Logged out of ${provider?.name ?? providerId}`);
201
- onAuthReloaded();
235
+ try {
236
+ // Managed providers tear down the WHOLE provider (every rotation account + the auth.json
237
+ // default record) through the coordinated service, never a raw authStorage.logout() that
238
+ // would strip only auth.json and orphan the pool.
239
+ if (isManagedProvider(providerId))
240
+ removeManagedProvider(providerId);
241
+ else
242
+ authStorage.logout(providerId);
243
+ close();
244
+ notify(`Logged out of ${provider?.name ?? providerId}`);
245
+ onAuthReloaded();
246
+ }
247
+ catch (error) {
248
+ close();
249
+ notify(`Logout error: ${error instanceof Error ? error.message : String(error)}`);
250
+ }
202
251
  };
203
252
  const component = new OAuthSelectorComponent('logout', authStorage, providers, onSelect, () => close(), getAuthStatus);
204
253
  return { component, focus: component };
@@ -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
  }
@@ -1,4 +1,5 @@
1
1
  import { type Component, type TUI } from '@earendil-works/pi-tui';
2
+ import { type BindingResolution, type BindingId } from '../../core/keybindings/index.js';
2
3
  import { type CanvasSource } from '../../core/canvas/source.js';
3
4
  import type { AttachPalette } from './config-load.js';
4
5
  export declare class GraphOverlay implements Component {
@@ -6,6 +7,7 @@ export declare class GraphOverlay implements Component {
6
7
  private readonly self;
7
8
  private readonly getAsks;
8
9
  private readonly palette;
10
+ private bindings;
9
11
  private readonly source;
10
12
  private handle;
11
13
  /** Manual fold OVERRIDES (h collapses → userCollapsed, l expands → userExpanded);
@@ -17,7 +19,7 @@ export declare class GraphOverlay implements Component {
17
19
  private cursorId;
18
20
  /** Viewport scroll offset (row index of the top visible tree row). */
19
21
  private scrollTop;
20
- /** Transient y/n confirm gate (kill). */
22
+ /** Transient close confirmation gate. */
21
23
  private pendingConfirm;
22
24
  private snapshot;
23
25
  private snapshotSeq;
@@ -26,7 +28,15 @@ export declare class GraphOverlay implements Component {
26
28
  * default and makes Enter/m/x no-op instead of shelling local focus/kill
27
29
  * commands against a remote graph row. See the file header. */
28
30
  private readonly remote;
29
- constructor(tui: TUI, self: string, getAsks: () => Record<string, number>, palette: AttachPalette, source?: CanvasSource);
31
+ constructor(tui: TUI, self: string, getAsks: () => Record<string, number>, palette: AttachPalette, bindings: BindingResolution<BindingId>, source?: CanvasSource);
32
+ setBindings(bindings: BindingResolution<BindingId>): void;
33
+ private matches;
34
+ private hint;
35
+ /** Compose footer hints in priority order, dropping WHOLE entries that would
36
+ * overflow `budget` visible columns rather than letting the border mid-clip
37
+ * the tail — so the prioritized head (dismiss + remapped local actions) stays
38
+ * discoverable even in a narrow overlay. */
39
+ private fitHints;
30
40
  isOpen(): boolean;
31
41
  open(): void;
32
42
  close(): void;
@@ -1,4 +1,4 @@
1
- // graph-overlay.ts — the alt+g GRAPH navigator overlay for `crtr surface attach` (Unit Q).
1
+ // graph-overlay.ts — the configurable GRAPH navigator overlay for `crtr surface attach`.
2
2
  //
3
3
  // Native reimplementation of canvas-nav.ts's GRAPH modal as a pi-tui OVERLAY
4
4
  // (the viewer has no pi extension host). `tui.showOverlay(this, …)` mounts this
@@ -6,12 +6,9 @@
6
6
  // CAPTURES keyboard focus (mirrors extension-dialogs.ts);
7
7
  // `OverlayHandle.hide()` tears it down and restores focus to the editor. While
8
8
  // shown, every key routes to handleInput() — a fold-aware NERDTree of the
9
- // active CanvasSource subscription graph with the canvas-nav keymap:
10
- // j/k move · h/l fold/ascend-descend · g/G top/bottom · focus (swap into
11
- // this pane) · m focus manager · x kill (y/n confirm) · esc close.
12
- // `e` is a tmux-menu prefixBind, not a GRAPH key, so it is swallowed (per
13
- // canvas-nav). Model/render uses the CanvasSource snapshot built for this
14
- // overlay; keys are decoded with pi-tui's canonical matchesKey (kitty/CSI-u aware).
9
+ // active CanvasSource subscription graph. Actions and footer hints resolve from
10
+ // the crouter binding catalog; input is decoded with pi-tui's canonical matcher
11
+ // (kitty/CSI-u aware).
15
12
  //
16
13
  // Enter/m/x SHELL `crtr` out-of-process (node focus / node lifecycle close) — the viewer
17
14
  // itself never spawns pi or opens a session (the §0 one-writer invariant). It
@@ -30,6 +27,7 @@
30
27
  // graph structure + node metadata ONLY.
31
28
  import { execFile } from 'node:child_process';
32
29
  import { matchesKey, truncateToWidth } from '@earendil-works/pi-tui';
30
+ import { formatBinding, matchesPiTuiInput, } from '../../core/keybindings/index.js';
33
31
  import { fullName } from '../../core/canvas/index.js';
34
32
  import { activeFaultForDisplay } from '../../core/canvas/render.js';
35
33
  import { readFault } from '../../core/runtime/fault.js';
@@ -43,18 +41,34 @@ import { beginFrame, navLabel, nodeGlyph, tokensCell, cycleBadge, askBadge, acti
43
41
  // clipped. margin keeps it off the screen edges.
44
42
  const HEIGHT_PCT = 0.72;
45
43
  const OVERLAY_OPTIONS = { anchor: 'center', width: '72%', minWidth: 48, maxHeight: '72%', margin: 1 };
46
- // Viewer-specific hint: nav-model's shared GRAPH_HINT advertises "e expand",
47
- // but that key is the tmux pane-expand prefix-bind canvas-nav installs in its
48
- // host pane — the attach overlay has no such bind and swallows `e`, so the key
49
- // would do nothing here. Drop it.
50
- const GRAPH_HINT = `${DIM}jk move · hl fold · ↵ focus · x kill · m mgr · esc${RESET}`;
51
- // REMOTE mode drops focus/kill/mgr — Enter/m/x no-op there (see the file header).
52
- const GRAPH_HINT_REMOTE = `${DIM}jk move · hl fold · esc${RESET}`;
44
+ // The footer is width-budgeted (fitHints drops WHOLE trailing entries that
45
+ // won't fit rather than mid-clipping the tail). List the keys a user most needs
46
+ // to discover first dismiss (the only way out) then the remapped local
47
+ // actions so they survive at the narrowest overlay width; routine navigation
48
+ // trails and is the first to be dropped.
49
+ const GRAPH_PRIMARY_LOCAL_HINTS = [
50
+ ['crtr.graph.dismiss', 'dismiss'],
51
+ ['crtr.graph.focus', 'focus'],
52
+ ['crtr.graph.close-subtree', 'close'],
53
+ ['crtr.graph.focus-manager', 'manager'],
54
+ ];
55
+ const GRAPH_PRIMARY_REMOTE_HINTS = [
56
+ ['crtr.graph.dismiss', 'dismiss'],
57
+ ];
58
+ const GRAPH_NAV_HINTS = [
59
+ ['crtr.graph.down', 'down'],
60
+ ['crtr.graph.up', 'up'],
61
+ ['crtr.graph.first', 'first'],
62
+ ['crtr.graph.last', 'last'],
63
+ ['crtr.graph.collapse-or-manager', 'fold/manager'],
64
+ ['crtr.graph.expand-or-child', 'expand/child'],
65
+ ];
53
66
  export class GraphOverlay {
54
67
  tui;
55
68
  self;
56
69
  getAsks;
57
70
  palette;
71
+ bindings;
58
72
  source;
59
73
  handle;
60
74
  /** Manual fold OVERRIDES (h collapses → userCollapsed, l expands → userExpanded);
@@ -66,7 +80,7 @@ export class GraphOverlay {
66
80
  cursorId;
67
81
  /** Viewport scroll offset (row index of the top visible tree row). */
68
82
  scrollTop = 0;
69
- /** Transient y/n confirm gate (kill). */
83
+ /** Transient close confirmation gate. */
70
84
  pendingConfirm;
71
85
  snapshot;
72
86
  snapshotSeq = 0;
@@ -75,14 +89,43 @@ export class GraphOverlay {
75
89
  * default and makes Enter/m/x no-op instead of shelling local focus/kill
76
90
  * commands against a remote graph row. See the file header. */
77
91
  remote;
78
- constructor(tui, self, getAsks, palette, source = localCanvasSource) {
92
+ constructor(tui, self, getAsks, palette, bindings, source = localCanvasSource) {
79
93
  this.tui = tui;
80
94
  this.self = self;
81
95
  this.getAsks = getAsks;
82
96
  this.palette = palette;
97
+ this.bindings = bindings;
83
98
  this.source = source;
84
99
  this.remote = source instanceof RemoteCanvasSource;
85
100
  }
101
+ setBindings(bindings) {
102
+ this.bindings = bindings;
103
+ if (this.handle !== undefined)
104
+ this.tui.requestRender();
105
+ }
106
+ matches(id, data) {
107
+ return matchesPiTuiInput(this.bindings, id, data, (input, gesture) => matchesKey(input, gesture));
108
+ }
109
+ hint(entries) {
110
+ return entries.flatMap(([id, label]) => this.bindings.gestures(id).length === 0 ? [] : [`${formatBinding(this.bindings, id)} ${label}`]);
111
+ }
112
+ /** Compose footer hints in priority order, dropping WHOLE entries that would
113
+ * overflow `budget` visible columns rather than letting the border mid-clip
114
+ * the tail — so the prioritized head (dismiss + remapped local actions) stays
115
+ * discoverable even in a narrow overlay. */
116
+ fitHints(entries, budget) {
117
+ const SEP = 3; // visible width of ' · '
118
+ const out = [];
119
+ let used = 0;
120
+ for (const piece of this.hint(entries)) {
121
+ const add = (out.length === 0 ? 0 : SEP) + visibleWidth(piece);
122
+ if (used + add > budget)
123
+ break;
124
+ out.push(piece);
125
+ used += add;
126
+ }
127
+ return out.join(' · ');
128
+ }
86
129
  isOpen() {
87
130
  return this.handle !== undefined;
88
131
  }
@@ -341,8 +384,14 @@ export class GraphOverlay {
341
384
  ? `${BOLD}⌗ canvas graph${RESET} ${DIM}(${cursorIdx + 1}/${rows.length})${RESET}`
342
385
  : `${BOLD}⌗ canvas graph${RESET} ${DIM}(loading)${RESET}`;
343
386
  const hint = this.pendingConfirm !== undefined
344
- ? `${YELLOW}${this.pendingConfirm.label} ${BOLD}y/n${RESET}`
345
- : this.remote ? GRAPH_HINT_REMOTE : GRAPH_HINT;
387
+ ? `${YELLOW}${this.pendingConfirm.label} ${BOLD}${this.hint([
388
+ ['crtr.graph.confirm.accept', 'accept'],
389
+ ['crtr.graph.confirm.cancel', 'cancel'],
390
+ ]).join(' · ')}${RESET}`
391
+ : `${DIM}${this.fitHints([
392
+ ...(this.remote ? GRAPH_PRIMARY_REMOTE_HINTS : GRAPH_PRIMARY_LOCAL_HINTS),
393
+ ...GRAPH_NAV_HINTS,
394
+ ], Math.max(1, width - 5))}${RESET}`; // budget matches borderRow's span
346
395
  // Frame the panel: title in the top border, hint in the bottom border, tree
347
396
  // rows boxed with themed side bars (truncate/pad to the inner width so the
348
397
  // right edge stays flush and opaque).
@@ -385,18 +434,19 @@ export class GraphOverlay {
385
434
  dispatch(data) {
386
435
  // y/n confirm gate takes precedence over everything.
387
436
  if (this.pendingConfirm !== undefined) {
388
- if (matchesKey(data, 'y')) {
437
+ if (this.matches('crtr.graph.confirm.accept', data)) {
389
438
  const act = this.pendingConfirm.action;
390
439
  this.pendingConfirm = undefined;
391
440
  act();
392
441
  }
393
442
  else {
394
- this.pendingConfirm = undefined; // any other key cancels
443
+ // Preserve GRAPH's modal fallback: any non-accept key cancels close.
444
+ this.pendingConfirm = undefined;
395
445
  }
396
446
  this.tui.requestRender();
397
447
  return;
398
448
  }
399
- if (matchesKey(data, 'escape')) {
449
+ if (this.matches('crtr.graph.dismiss', data)) {
400
450
  this.close();
401
451
  return;
402
452
  }
@@ -427,29 +477,29 @@ export class GraphOverlay {
427
477
  }
428
478
  return idx;
429
479
  };
430
- if (matchesKey(data, 'j')) {
480
+ if (this.matches('crtr.graph.down', data)) {
431
481
  idx = step(idx, 1);
432
482
  this.cursorId = rows[idx]?.id ?? this.cursorId;
433
483
  this.tui.requestRender();
434
484
  return;
435
485
  }
436
- if (matchesKey(data, 'k')) {
486
+ if (this.matches('crtr.graph.up', data)) {
437
487
  idx = step(idx, -1);
438
488
  this.cursorId = rows[idx]?.id ?? this.cursorId;
439
489
  this.tui.requestRender();
440
490
  return;
441
491
  }
442
- if (matchesKey(data, 'g')) {
492
+ if (this.matches('crtr.graph.first', data)) {
443
493
  this.cursorId = rows[edge(1)]?.id ?? this.cursorId;
444
494
  this.tui.requestRender();
445
495
  return;
446
496
  }
447
- if (matchesKey(data, 'shift+g')) {
497
+ if (this.matches('crtr.graph.last', data)) {
448
498
  this.cursorId = rows[edge(-1)]?.id ?? this.cursorId;
449
499
  this.tui.requestRender();
450
500
  return;
451
501
  }
452
- if (matchesKey(data, 'h')) {
502
+ if (this.matches('crtr.graph.collapse-or-manager', data)) {
453
503
  if (cur !== undefined && cur.hasKids && !cur.collapsed) {
454
504
  this.userCollapsed.add(cur.id);
455
505
  this.userExpanded.delete(cur.id);
@@ -463,7 +513,7 @@ export class GraphOverlay {
463
513
  }
464
514
  return;
465
515
  }
466
- if (matchesKey(data, 'l')) {
516
+ if (this.matches('crtr.graph.expand-or-child', data)) {
467
517
  if (cur !== undefined && cur.collapsed && cur.hasKids) {
468
518
  this.userExpanded.add(cur.id);
469
519
  this.userCollapsed.delete(cur.id);
@@ -477,12 +527,12 @@ export class GraphOverlay {
477
527
  }
478
528
  return;
479
529
  }
480
- // Enter/m/x drive local lifecycle/focus (`crtr node focus` / `crtr node
530
+ // Focus/manager/close actions drive local lifecycle/focus (`crtr node focus` / `crtr node
481
531
  // lifecycle close`) against `this.cursorId` — a REMOTE graph row's id has no
482
532
  // relationship to this machine's canvas.db (or, worse, collides with one
483
533
  // that does), so all three no-op in remote mode instead of shelling a local
484
- // mutation command against a remote id. GRAPH_HINT_REMOTE drops their hints.
485
- if (matchesKey(data, 'enter')) {
534
+ // mutation command against a remote id. Their hints are omitted remotely.
535
+ if (this.matches('crtr.graph.focus', data)) {
486
536
  if (this.remote)
487
537
  return;
488
538
  if (this.cursorId !== undefined)
@@ -490,7 +540,7 @@ export class GraphOverlay {
490
540
  this.close();
491
541
  return;
492
542
  }
493
- if (matchesKey(data, 'm')) {
543
+ if (this.matches('crtr.graph.focus-manager', data)) {
494
544
  if (this.remote)
495
545
  return;
496
546
  const mgr = snapshot?.managerOf.get(this.self);
@@ -500,7 +550,7 @@ export class GraphOverlay {
500
550
  }
501
551
  return;
502
552
  }
503
- if (matchesKey(data, 'x')) {
553
+ if (this.matches('crtr.graph.close-subtree', data)) {
504
554
  if (this.remote)
505
555
  return;
506
556
  const target = this.cursorId ?? this.self;
@@ -510,7 +560,7 @@ export class GraphOverlay {
510
560
  this.tui.requestRender();
511
561
  return;
512
562
  }
513
- // Modal: swallow everything else (incl. the `e` tmux-menu bind) so a stray
563
+ // Modal: swallow everything else so a stray
514
564
  // key never reaches the editor underneath.
515
565
  }
516
566
  // -------------------------------------------------------------------------
@@ -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;