@crouton-kit/crouter 0.3.70 → 0.3.79

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 (193) hide show
  1. package/README.md +1 -1
  2. package/dist/build-root.d.ts +12 -4
  3. package/dist/build-root.js +25 -6
  4. package/dist/builtin-memory/00-runtime-base.md +3 -10
  5. package/dist/builtin-memory/04-base-worker.md +18 -0
  6. package/dist/builtin-memory/04-orchestration-kernel.md +1 -1
  7. package/dist/builtin-memory/crouter-development/plugins.md +82 -5
  8. package/dist/builtin-pi-packages/pi-crtr-extensions/README.md +13 -34
  9. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/__tests__/provider-rotation.test.ts +1115 -1
  10. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.ts +234 -71
  11. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/statusline.ts +2 -9
  12. package/dist/builtin-pi-packages/pi-crtr-extensions/lib/subscription-state.ts +2 -516
  13. package/dist/builtin-pi-packages/pi-crtr-extensions/package-lock.json +2 -2
  14. package/dist/builtin-pi-packages/pi-mode-switch/README.md +11 -39
  15. package/dist/builtin-pi-packages/pi-mode-switch/extensions/index.ts +20 -15
  16. package/dist/builtin-pi-packages/pi-mode-switch/package.json +1 -1
  17. package/dist/builtin-views/canvas/tui.mjs +8 -9
  18. package/dist/builtin-views/chat/tui.mjs +12 -12
  19. package/dist/builtin-views/git-pr/tui.mjs +7 -8
  20. package/dist/builtin-views/inbox/tui.mjs +27 -41
  21. package/dist/builtin-views/linkedin/tui.mjs +23 -37
  22. package/dist/builtin-views/prompt-review/tui.mjs +11 -11
  23. package/dist/builtin-views/settings/tui.mjs +11 -11
  24. package/dist/builtin-views/workspace-sidebar/tui.mjs +8 -9
  25. package/dist/clients/attach/__tests__/attach-chrome-remote.test.js +11 -6
  26. package/dist/clients/attach/__tests__/attach-keybindings.test.d.ts +1 -0
  27. package/dist/clients/attach/__tests__/attach-keybindings.test.js +113 -0
  28. package/dist/clients/attach/__tests__/crtr-output-render.test.js +1 -1
  29. package/dist/clients/attach/__tests__/mermaid-render.test.d.ts +1 -0
  30. package/dist/clients/attach/__tests__/mermaid-render.test.js +28 -0
  31. package/dist/clients/attach/__tests__/oauth-dialog-lifecycle.test.d.ts +1 -0
  32. package/dist/clients/attach/__tests__/oauth-dialog-lifecycle.test.js +110 -0
  33. package/dist/clients/attach/attach-cmd.d.ts +9 -1
  34. package/dist/clients/attach/attach-cmd.js +841 -792
  35. package/dist/clients/attach/auth-pickers.d.ts +0 -12
  36. package/dist/clients/attach/auth-pickers.js +64 -15
  37. package/dist/clients/attach/canvas-panels.js +2 -3
  38. package/dist/clients/attach/chat-view.d.ts +7 -8
  39. package/dist/clients/attach/chat-view.js +119 -79
  40. package/dist/clients/attach/edit-diff-render.d.ts +6 -0
  41. package/dist/clients/attach/edit-diff-render.js +263 -0
  42. package/dist/clients/attach/graph-overlay.d.ts +12 -2
  43. package/dist/clients/attach/graph-overlay.js +83 -33
  44. package/dist/clients/attach/input-controller.d.ts +28 -2
  45. package/dist/clients/attach/input-controller.js +38 -5
  46. package/dist/clients/attach/mermaid-render.js +5 -2
  47. package/dist/clients/attach/pickers.d.ts +8 -7
  48. package/dist/clients/attach/pickers.js +11 -17
  49. package/dist/clients/attach/slash-commands.d.ts +9 -0
  50. package/dist/clients/attach/slash-commands.js +127 -7
  51. package/dist/clients/attach/titled-editor.d.ts +12 -1
  52. package/dist/clients/attach/titled-editor.js +103 -8
  53. package/dist/commands/canvas-browse.js +2 -2
  54. package/dist/commands/human/queue.js +3 -4
  55. package/dist/commands/memory/lint.js +39 -5
  56. package/dist/commands/memory/write.js +1 -0
  57. package/dist/commands/node.js +9 -2
  58. package/dist/commands/pkg/plugin-inspect.js +22 -1
  59. package/dist/commands/pkg/plugin-manage.js +31 -9
  60. package/dist/commands/surface-tmux-spread.js +1 -3
  61. package/dist/commands/sys/__tests__/config-keybindings.test.d.ts +1 -0
  62. package/dist/commands/sys/__tests__/config-keybindings.test.js +55 -0
  63. package/dist/commands/sys/__tests__/config-model-ladders.test.d.ts +1 -0
  64. package/dist/commands/sys/__tests__/config-model-ladders.test.js +121 -0
  65. package/dist/commands/sys/__tests__/setup-core.test.js +158 -1
  66. package/dist/commands/sys/config.js +18 -21
  67. package/dist/commands/sys/doctor.js +42 -4
  68. package/dist/commands/sys/setup-core.d.ts +49 -1
  69. package/dist/commands/sys/setup-core.js +161 -4
  70. package/dist/commands/sys/setup.d.ts +88 -0
  71. package/dist/commands/sys/setup.js +943 -169
  72. package/dist/commands/view-pick.d.ts +4 -0
  73. package/dist/commands/view-pick.js +17 -7
  74. package/dist/core/__tests__/base-worker-prompt.test.d.ts +1 -0
  75. package/dist/core/__tests__/base-worker-prompt.test.js +24 -0
  76. package/dist/core/__tests__/canvas-inbox-watcher-hold.test.js +232 -1
  77. package/dist/core/__tests__/canvas-inbox-watcher.test.js +34 -9
  78. package/dist/core/__tests__/command-plugins-surfaces.test.d.ts +1 -0
  79. package/dist/core/__tests__/command-plugins-surfaces.test.js +298 -0
  80. package/dist/core/__tests__/command-plugins.test.d.ts +1 -0
  81. package/dist/core/__tests__/command-plugins.test.js +444 -0
  82. package/dist/core/__tests__/fault-classifier.test.js +15 -0
  83. package/dist/core/__tests__/fixtures/fake-engine.d.ts +6 -0
  84. package/dist/core/__tests__/fixtures/fake-engine.js +9 -1
  85. package/dist/core/__tests__/full/broker-dialogs.test.js +7 -2
  86. package/dist/core/__tests__/host-teardown-process-group.test.js +15 -4
  87. package/dist/core/__tests__/preview-registry-sync.test.js +30 -1
  88. package/dist/core/__tests__/scope-crouter-home-fence.test.d.ts +1 -0
  89. package/dist/core/__tests__/scope-crouter-home-fence.test.js +55 -0
  90. package/dist/core/__tests__/stream-watchdog.test.d.ts +1 -0
  91. package/dist/core/__tests__/stream-watchdog.test.js +70 -0
  92. package/dist/core/__tests__/tmux-surface.test.js +72 -0
  93. package/dist/core/canvas/browse/__tests__/model.test.js +23 -5
  94. package/dist/core/canvas/browse/__tests__/render.test.js +1 -0
  95. package/dist/core/canvas/browse/app.d.ts +6 -0
  96. package/dist/core/canvas/browse/app.js +230 -41
  97. package/dist/core/canvas/browse/model.d.ts +10 -6
  98. package/dist/core/canvas/browse/model.js +20 -12
  99. package/dist/core/canvas/browse/pins.d.ts +4 -0
  100. package/dist/core/canvas/browse/pins.js +29 -0
  101. package/dist/core/canvas/browse/render.d.ts +13 -1
  102. package/dist/core/canvas/browse/render.js +72 -15
  103. package/dist/core/canvas/nav-model.js +5 -11
  104. package/dist/core/canvas/paths.d.ts +5 -0
  105. package/dist/core/canvas/paths.js +7 -0
  106. package/dist/core/command-plugins/adapter.d.ts +15 -0
  107. package/dist/core/command-plugins/adapter.js +145 -0
  108. package/dist/core/command-plugins/compose.d.ts +5 -0
  109. package/dist/core/command-plugins/compose.js +56 -0
  110. package/dist/core/command-plugins/discovery.d.ts +104 -0
  111. package/dist/core/command-plugins/discovery.js +565 -0
  112. package/dist/core/config.d.ts +48 -0
  113. package/dist/core/config.js +228 -110
  114. package/dist/core/fault-classifier.js +1 -1
  115. package/dist/core/fs-utils.d.ts +1 -0
  116. package/dist/core/fs-utils.js +15 -1
  117. package/dist/core/keybindings/__tests__/bespoke-consumers.test.d.ts +1 -0
  118. package/dist/core/keybindings/__tests__/bespoke-consumers.test.js +40 -0
  119. package/dist/core/keybindings/__tests__/resolve.test.d.ts +1 -0
  120. package/dist/core/keybindings/__tests__/resolve.test.js +224 -0
  121. package/dist/core/keybindings/catalog.d.ts +14 -0
  122. package/dist/core/keybindings/catalog.js +257 -0
  123. package/dist/core/keybindings/index.d.ts +5 -0
  124. package/dist/core/keybindings/index.js +4 -0
  125. package/dist/core/keybindings/match.d.ts +29 -0
  126. package/dist/core/keybindings/match.js +99 -0
  127. package/dist/core/keybindings/persistence.d.ts +14 -0
  128. package/dist/core/keybindings/persistence.js +31 -0
  129. package/dist/core/keybindings/resolve.d.ts +14 -0
  130. package/dist/core/keybindings/resolve.js +305 -0
  131. package/dist/core/keybindings/types.d.ts +37 -0
  132. package/dist/core/keybindings/types.js +1 -0
  133. package/dist/core/memory-resolver.d.ts +1 -1
  134. package/dist/core/memory-resolver.js +10 -3
  135. package/dist/core/predicate.d.ts +5 -3
  136. package/dist/core/predicate.js +5 -3
  137. package/dist/core/profiles/select.d.ts +6 -0
  138. package/dist/core/profiles/select.js +86 -52
  139. package/dist/core/provider-management.d.ts +12 -0
  140. package/dist/core/provider-management.js +24 -0
  141. package/dist/core/runtime/broker-protocol.d.ts +33 -5
  142. package/dist/core/runtime/broker.js +164 -14
  143. package/dist/core/runtime/launch.d.ts +39 -6
  144. package/dist/core/runtime/launch.js +78 -20
  145. package/dist/core/runtime/naming.js +3 -3
  146. package/dist/core/runtime/pi-cli.d.ts +6 -0
  147. package/dist/core/runtime/pi-cli.js +16 -2
  148. package/dist/core/runtime/pi-vendored.d.ts +8 -0
  149. package/dist/core/runtime/pi-vendored.js +14 -0
  150. package/dist/core/runtime/placement.d.ts +2 -2
  151. package/dist/core/runtime/placement.js +4 -1
  152. package/dist/core/runtime/promote.js +4 -0
  153. package/dist/core/runtime/recap.d.ts +3 -3
  154. package/dist/core/runtime/recap.js +75 -47
  155. package/dist/core/runtime/recycle.js +6 -1
  156. package/dist/core/runtime/reset.js +5 -0
  157. package/dist/core/runtime/session-list-cache.d.ts +33 -0
  158. package/dist/core/runtime/session-list-cache.js +338 -0
  159. package/dist/core/runtime/spawn.js +52 -45
  160. package/dist/core/runtime/stream-watchdog.d.ts +26 -0
  161. package/dist/core/runtime/stream-watchdog.js +75 -0
  162. package/dist/core/runtime/tmux-chrome.d.ts +1 -1
  163. package/dist/core/runtime/tmux-chrome.js +2 -2
  164. package/dist/core/runtime/tmux.d.ts +18 -15
  165. package/dist/core/runtime/tmux.js +217 -110
  166. package/dist/core/scope.js +27 -4
  167. package/dist/core/subscription-state.d.ts +90 -0
  168. package/dist/core/subscription-state.js +762 -0
  169. package/dist/core/tui/__tests__/host-keybindings.test.d.ts +1 -0
  170. package/dist/core/tui/__tests__/host-keybindings.test.js +112 -0
  171. package/dist/core/tui/host.d.ts +30 -1
  172. package/dist/core/tui/host.js +148 -34
  173. package/dist/core/view/contract.d.ts +17 -3
  174. package/dist/daemon/crtrd.js +253 -12
  175. package/dist/pi-extensions/__tests__/canvas-stophook-agentend.test.js +41 -0
  176. package/dist/pi-extensions/__tests__/canvas-stophook-context-nudge.test.d.ts +1 -0
  177. package/dist/pi-extensions/__tests__/canvas-stophook-context-nudge.test.js +121 -0
  178. package/dist/pi-extensions/canvas-inbox-watcher.d.ts +1 -1
  179. package/dist/pi-extensions/canvas-inbox-watcher.js +203 -25
  180. package/dist/pi-extensions/canvas-recap.d.ts +4 -0
  181. package/dist/pi-extensions/canvas-recap.js +73 -46
  182. package/dist/pi-extensions/canvas-stophook.d.ts +11 -0
  183. package/dist/pi-extensions/canvas-stophook.js +33 -4
  184. package/dist/types.d.ts +9 -26
  185. package/dist/types.js +1 -28
  186. package/dist/web-client/assets/{index-DiFuLcp6.js → index--md2ylfi.js} +1 -1
  187. package/dist/web-client/index.html +1 -1
  188. package/dist/web-client/sw.js +1 -1
  189. package/package.json +8 -4
  190. package/dist/pi-extensions/canvas-nav.d.ts +0 -43
  191. package/dist/pi-extensions/canvas-nav.js +0 -640
  192. package/dist/pi-extensions/widget-order-bus.d.ts +0 -6
  193. package/dist/pi-extensions/widget-order-bus.js +0 -34
@@ -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 };
@@ -1,8 +1,7 @@
1
1
  // canvas-panels.ts — the subscribed-node panel for `crtr surface attach` (Unit Q).
2
2
  //
3
- // Native implementation of canvas-nav.ts's BASE chrome (renderBase), reading
4
- // through the CanvasSource seam so the viewer chrome and graph overlay share the
5
- // same canvas read authority. Produces the two line stacks the viewer paints
3
+ // Reads through the CanvasSource seam so the viewer chrome and graph overlay
4
+ // share the same canvas read authority. Produces the two line stacks the viewer paints
6
5
  // into Containers around the editor:
7
6
  // managers → ABOVE the editor: this node's manager (its first subscriber).
8
7
  // reports → BELOW the editor: this node's live reports (active|idle
@@ -28,9 +28,10 @@ export declare class ChatView {
28
28
  private readonly tui;
29
29
  /** The chat scroll area handed in by the attach command. */
30
30
  private readonly container;
31
- /** Activity/status area, pinned as the LAST child of `container` (mirrors pi's
32
- * statusContainer sitting directly below chatContainer). Holds the working
33
- * spinner / compaction+retry loaders. */
31
+ /** Settled history is cached independently from the live status area so picker
32
+ * navigation does not re-render a long conversation on every key repeat. */
33
+ private readonly historyContainer;
34
+ /** Activity/status area below history. Holds working/compaction/retry loaders. */
34
35
  private readonly statusContainer;
35
36
  private readonly cwd;
36
37
  private readonly showImages;
@@ -54,8 +55,6 @@ export declare class ChatView {
54
55
  private bashComponent;
55
56
  /** The single active activity loader (working/compaction/retry), if any. */
56
57
  private activityLoader;
57
- /** While true, `append` skips re-pinning the status container (bulk rebuild). */
58
- private bulkMode;
59
58
  /** Text of the most recent assistant message, kept current from the welcome
60
59
  * snapshot AND the live event stream so `/copy` (getLastAssistantText) works
61
60
  * whether the message arrived before or after attach. */
@@ -91,15 +90,15 @@ export declare class ChatView {
91
90
  private addMessageToChat;
92
91
  /** Tool component with native viewer renderers for crouter-owned tool output. */
93
92
  private makeToolComponent;
94
- /** Append a child while keeping the activity/status area pinned to the bottom. */
93
+ /** Append conversation content above the independent live status area. */
95
94
  private append;
96
95
  /** Approximate render width for inline mermaid diagrams. Only affects
97
96
  * termrender's trailing padding (stripped in the transform), so the terminal
98
97
  * column count is close enough without threading the live pane width here. */
99
98
  private renderWidth;
100
- /** Number of chat children excluding the pinned status container. */
99
+ /** Number of rendered conversation components. */
101
100
  private chatChildCount;
102
- /** Clear all chat content and re-pin an empty status container. */
101
+ /** Clear all chat content. */
103
102
  private resetChat;
104
103
  private makeLoader;
105
104
  /** Swap the single active activity indicator (working/compaction/retry). */
@@ -27,6 +27,7 @@ import { CRTR_OUTPUT_CUSTOM_TYPE, CrtrOutputMessageComponent, createCrtrBashTool
27
27
  import { CRTR_CYCLE_DIVIDER_CUSTOM_TYPE } from '../../core/runtime/session-cycles.js';
28
28
  import { SITUATIONAL_CONTEXT_CUSTOM_TYPE } from '../../core/runtime/situational-context.js';
29
29
  import { transformMessageMermaid } from './mermaid-render.js';
30
+ import { createCrtrEditToolDefinition } from './edit-diff-render.js';
30
31
  /** customType stamped on the injected `<crtr-context>` bearings block (mirrors
31
32
  * CONTEXT_INTRO_CUSTOM_TYPE in canvas-context-intro.ts — kept as a local literal
32
33
  * so the viewer never imports the in-process extension). */
@@ -51,13 +52,56 @@ function isExpandable(obj) {
51
52
  typeof obj.setExpanded === 'function');
52
53
  }
53
54
  const defaultErrorStyle = (s) => `\x1b[31m${s}\x1b[39m`;
55
+ /** Keep settled conversation history out of picker/editor render hot paths. The
56
+ * root TUI still compares the cached lines, but it no longer re-renders every
57
+ * markdown/tool component for each repeated navigation key. */
58
+ class RenderCacheContainer extends Container {
59
+ cachedWidth;
60
+ cachedLines;
61
+ volatile = false;
62
+ markDirty() {
63
+ this.cachedLines = undefined;
64
+ }
65
+ setVolatile(value) {
66
+ this.volatile = value;
67
+ if (value)
68
+ this.markDirty();
69
+ }
70
+ addChild(component) {
71
+ super.addChild(component);
72
+ this.markDirty();
73
+ }
74
+ removeChild(component) {
75
+ super.removeChild(component);
76
+ this.markDirty();
77
+ }
78
+ clear() {
79
+ super.clear();
80
+ this.markDirty();
81
+ }
82
+ invalidate() {
83
+ super.invalidate();
84
+ this.markDirty();
85
+ }
86
+ render(width) {
87
+ if (!this.volatile && this.cachedLines !== undefined && this.cachedWidth === width)
88
+ return this.cachedLines;
89
+ const lines = super.render(width);
90
+ if (!this.volatile) {
91
+ this.cachedWidth = width;
92
+ this.cachedLines = lines;
93
+ }
94
+ return lines;
95
+ }
96
+ }
54
97
  export class ChatView {
55
98
  tui;
56
99
  /** The chat scroll area handed in by the attach command. */
57
100
  container;
58
- /** Activity/status area, pinned as the LAST child of `container` (mirrors pi's
59
- * statusContainer sitting directly below chatContainer). Holds the working
60
- * spinner / compaction+retry loaders. */
101
+ /** Settled history is cached independently from the live status area so picker
102
+ * navigation does not re-render a long conversation on every key repeat. */
103
+ historyContainer = new RenderCacheContainer();
104
+ /** Activity/status area below history. Holds working/compaction/retry loaders. */
61
105
  statusContainer = new Container();
62
106
  cwd;
63
107
  showImages;
@@ -81,8 +125,6 @@ export class ChatView {
81
125
  bashComponent;
82
126
  /** The single active activity loader (working/compaction/retry), if any. */
83
127
  activityLoader;
84
- /** While true, `append` skips re-pinning the status container (bulk rebuild). */
85
- bulkMode = false;
86
128
  /** Text of the most recent assistant message, kept current from the welcome
87
129
  * snapshot AND the live event stream so `/copy` (getLastAssistantText) works
88
130
  * whether the message arrived before or after attach. */
@@ -100,6 +142,7 @@ export class ChatView {
100
142
  this.spinnerStyle = opts.palette?.active ?? defaultSpinnerStyle;
101
143
  this.dimStyle = opts.palette?.muted ?? defaultDimStyle;
102
144
  this.errorStyle = opts.palette?.error ?? defaultErrorStyle;
145
+ this.container.addChild(this.historyContainer);
103
146
  this.container.addChild(this.statusContainer);
104
147
  }
105
148
  // -------------------------------------------------------------------------
@@ -115,69 +158,58 @@ export class ChatView {
115
158
  const renderedPendingTools = new Map();
116
159
  const messages = snapshot.messages;
117
160
  const lastIndex = messages.length - 1;
118
- // Rebuild in bulk: detach the pinned status area, append children directly,
119
- // re-pin ONCE at the end. Keeps the rebuild O(N) instead of O(N²) re-pins for
120
- // a long transcript (see `append`).
121
- this.container.removeChild(this.statusContainer);
122
- this.bulkMode = true;
123
- try {
124
- for (let i = 0; i < messages.length; i++) {
125
- const message = messages[i];
126
- if (message.role === 'assistant') {
127
- // Keep the `/copy` source current from the catch-up transcript too, not
128
- // only the live stream (you usually attach AFTER the last reply landed).
129
- const text = this.userMessageText(message);
130
- if (text.trim())
131
- this.lastAssistantText = text;
132
- // If the snapshot caught an IN-FLIGHT assistant turn (state.isStreaming
133
- // and this is the trailing, non-terminal assistant message), bind it as
134
- // the live streaming component so the relayed message_update/message_end
135
- // frames that follow `welcome` land on it. Otherwise the in-flight
136
- // message would freeze at its snapshot value until the next turn — the
137
- // common case, since you typically attach to a node that is working.
138
- const isStreamingTail = i === lastIndex &&
139
- snapshot.state.isStreaming &&
140
- message.stopReason !== 'aborted' &&
141
- message.stopReason !== 'error';
142
- if (isStreamingTail) {
143
- this.streamingComponent = new AssistantMessageComponent(undefined, this.hideThinking, getMarkdownTheme(), this.hiddenThinkingLabel);
144
- this.append(this.streamingComponent);
145
- this.streamingComponent.updateContent(message);
161
+ for (let i = 0; i < messages.length; i++) {
162
+ const message = messages[i];
163
+ if (message.role === 'assistant') {
164
+ // Keep the `/copy` source current from the catch-up transcript too, not
165
+ // only the live stream (you usually attach AFTER the last reply landed).
166
+ const text = this.userMessageText(message);
167
+ if (text.trim())
168
+ this.lastAssistantText = text;
169
+ // If the snapshot caught an IN-FLIGHT assistant turn (state.isStreaming
170
+ // and this is the trailing, non-terminal assistant message), bind it as
171
+ // the live streaming component so the relayed message_update/message_end
172
+ // frames that follow `welcome` land on it. Otherwise the in-flight
173
+ // message would freeze at its snapshot value until the next turn — the
174
+ // common case, since you typically attach to a node that is working.
175
+ const isStreamingTail = i === lastIndex &&
176
+ snapshot.state.isStreaming &&
177
+ message.stopReason !== 'aborted' &&
178
+ message.stopReason !== 'error';
179
+ if (isStreamingTail) {
180
+ this.streamingComponent = new AssistantMessageComponent(undefined, this.hideThinking, getMarkdownTheme(), this.hiddenThinkingLabel);
181
+ this.append(this.streamingComponent);
182
+ this.streamingComponent.updateContent(message);
183
+ }
184
+ else {
185
+ this.addMessageToChat(message);
186
+ }
187
+ for (const content of this.assistantToolCalls(message)) {
188
+ const component = this.makeToolComponent(content.name, content.id, content.arguments);
189
+ this.append(component);
190
+ // (isStreamingTail is never aborted/error, so its tools fall through to
191
+ // renderedPendingTools → pendingTools, where live tool events find them.)
192
+ if (message.stopReason === 'aborted' || message.stopReason === 'error') {
193
+ const errorMessage = message.stopReason === 'aborted'
194
+ ? 'Operation aborted'
195
+ : (message.errorMessage ?? 'Error');
196
+ component.updateResult({ content: [{ type: 'text', text: errorMessage }], isError: true });
146
197
  }
147
198
  else {
148
- this.addMessageToChat(message);
149
- }
150
- for (const content of this.assistantToolCalls(message)) {
151
- const component = this.makeToolComponent(content.name, content.id, content.arguments);
152
- this.append(component);
153
- // (isStreamingTail is never aborted/error, so its tools fall through to
154
- // renderedPendingTools → pendingTools, where live tool events find them.)
155
- if (message.stopReason === 'aborted' || message.stopReason === 'error') {
156
- const errorMessage = message.stopReason === 'aborted'
157
- ? 'Operation aborted'
158
- : (message.errorMessage ?? 'Error');
159
- component.updateResult({ content: [{ type: 'text', text: errorMessage }], isError: true });
160
- }
161
- else {
162
- renderedPendingTools.set(content.id, component);
163
- }
164
- }
165
- }
166
- else if (message.role === 'toolResult') {
167
- const component = renderedPendingTools.get(message.toolCallId);
168
- if (component) {
169
- component.updateResult(message);
170
- renderedPendingTools.delete(message.toolCallId);
199
+ renderedPendingTools.set(content.id, component);
171
200
  }
172
201
  }
173
- else {
174
- this.addMessageToChat(message);
202
+ }
203
+ else if (message.role === 'toolResult') {
204
+ const component = renderedPendingTools.get(message.toolCallId);
205
+ if (component) {
206
+ component.updateResult(message);
207
+ renderedPendingTools.delete(message.toolCallId);
175
208
  }
176
209
  }
177
- }
178
- finally {
179
- this.bulkMode = false;
180
- this.container.addChild(this.statusContainer);
210
+ else {
211
+ this.addMessageToChat(message);
212
+ }
181
213
  }
182
214
  for (const [id, component] of renderedPendingTools) {
183
215
  this.pendingTools.set(id, component);
@@ -185,6 +217,7 @@ export class ChatView {
185
217
  // If we attached mid-turn, the `agent_start` that spins up the working loader
186
218
  // already fired before we connected — restore it from the snapshot's streaming
187
219
  // state so an actively-working node shows the spinner right away.
220
+ this.historyContainer.setVolatile(snapshot.state.isStreaming);
188
221
  if (snapshot.state.isStreaming)
189
222
  this.setActivity(this.makeLoader('Working...'));
190
223
  this.tui.requestRender();
@@ -205,10 +238,11 @@ export class ChatView {
205
238
  * the caller can surface a notice. */
206
239
  toggleToolsExpanded() {
207
240
  this.toolOutputExpanded = !this.toolOutputExpanded;
208
- for (const child of this.container.children) {
241
+ for (const child of this.historyContainer.children) {
209
242
  if (isExpandable(child))
210
243
  child.setExpanded(this.toolOutputExpanded);
211
244
  }
245
+ this.historyContainer.markDirty();
212
246
  this.tui.requestRender();
213
247
  return this.toolOutputExpanded;
214
248
  }
@@ -217,10 +251,11 @@ export class ChatView {
217
251
  * the container, so the loop covers it too). Returns the new HIDDEN state. */
218
252
  toggleThinking() {
219
253
  this.hideThinking = !this.hideThinking;
220
- for (const child of this.container.children) {
254
+ for (const child of this.historyContainer.children) {
221
255
  if (child instanceof AssistantMessageComponent)
222
256
  child.setHideThinkingBlock(this.hideThinking);
223
257
  }
258
+ this.historyContainer.markDirty();
224
259
  this.tui.requestRender();
225
260
  return this.hideThinking;
226
261
  }
@@ -232,16 +267,18 @@ export class ChatView {
232
267
  switch (event.type) {
233
268
  case 'agent_start': {
234
269
  this.pendingTools.clear();
270
+ this.historyContainer.setVolatile(true);
235
271
  this.setActivity(this.makeLoader('Working...'));
236
272
  break;
237
273
  }
238
274
  case 'agent_end': {
239
275
  // Drop a dangling streaming component (e.g. aborted before message_end).
240
276
  if (this.streamingComponent) {
241
- this.container.removeChild(this.streamingComponent);
277
+ this.historyContainer.removeChild(this.streamingComponent);
242
278
  this.streamingComponent = undefined;
243
279
  }
244
280
  this.pendingTools.clear();
281
+ this.historyContainer.setVolatile(false);
245
282
  this.setActivity(undefined);
246
283
  break;
247
284
  }
@@ -393,6 +430,7 @@ export class ChatView {
393
430
  default:
394
431
  break;
395
432
  }
433
+ this.historyContainer.markDirty();
396
434
  this.tui.requestRender();
397
435
  }
398
436
  // -------------------------------------------------------------------------
@@ -404,16 +442,20 @@ export class ChatView {
404
442
  bashStart(command, excludeFromContext) {
405
443
  const component = new BashExecutionComponent(command, this.tui, excludeFromContext);
406
444
  this.bashComponent = component;
445
+ this.historyContainer.setVolatile(true);
407
446
  this.append(component);
408
447
  this.tui.requestRender();
409
448
  }
410
449
  bashOutput(chunk) {
411
450
  this.bashComponent?.appendOutput(chunk);
451
+ this.historyContainer.markDirty();
412
452
  this.tui.requestRender();
413
453
  }
414
454
  bashEnd(result) {
415
455
  this.bashComponent?.setComplete(result.exitCode, result.cancelled, result.truncated ? { truncated: true } : undefined, result.fullOutputPath);
416
456
  this.bashComponent = undefined;
457
+ this.historyContainer.setVolatile(false);
458
+ this.historyContainer.markDirty();
417
459
  this.tui.requestRender();
418
460
  }
419
461
  // -------------------------------------------------------------------------
@@ -524,20 +566,18 @@ export class ChatView {
524
566
  // -------------------------------------------------------------------------
525
567
  /** Tool component with native viewer renderers for crouter-owned tool output. */
526
568
  makeToolComponent(name, id, args) {
527
- const toolDefinition = name === 'bash' ? createCrtrBashToolDefinition(args) : undefined;
569
+ const toolDefinition = name === 'bash'
570
+ ? createCrtrBashToolDefinition(args)
571
+ : name === 'edit'
572
+ ? createCrtrEditToolDefinition(this.cwd)
573
+ : undefined;
528
574
  const component = new ToolExecutionComponent(name, id, args, { showImages: this.showImages, imageWidthCells: this.imageWidthCells }, toolDefinition, this.tui, this.cwd);
529
575
  component.setExpanded(this.toolOutputExpanded);
530
576
  return component;
531
577
  }
532
- /** Append a child while keeping the activity/status area pinned to the bottom. */
578
+ /** Append conversation content above the independent live status area. */
533
579
  append(child) {
534
- if (this.bulkMode) {
535
- this.container.addChild(child);
536
- return;
537
- }
538
- this.container.removeChild(this.statusContainer);
539
- this.container.addChild(child);
540
- this.container.addChild(this.statusContainer);
580
+ this.historyContainer.addChild(child);
541
581
  }
542
582
  /** Approximate render width for inline mermaid diagrams. Only affects
543
583
  * termrender's trailing padding (stripped in the transform), so the terminal
@@ -545,18 +585,18 @@ export class ChatView {
545
585
  renderWidth() {
546
586
  return Math.max(40, process.stdout.columns ?? 80);
547
587
  }
548
- /** Number of chat children excluding the pinned status container. */
588
+ /** Number of rendered conversation components. */
549
589
  chatChildCount() {
550
- return this.container.children.filter((c) => c !== this.statusContainer).length;
590
+ return this.historyContainer.children.length;
551
591
  }
552
- /** Clear all chat content and re-pin an empty status container. */
592
+ /** Clear all chat content. */
553
593
  resetChat() {
554
594
  this.setActivity(undefined);
555
595
  this.streamingComponent = undefined;
556
596
  this.pendingTools.clear();
557
597
  this.lastAssistantText = undefined;
558
- this.container.clear();
559
- this.container.addChild(this.statusContainer);
598
+ this.historyContainer.setVolatile(false);
599
+ this.historyContainer.clear();
560
600
  }
561
601
  makeLoader(message) {
562
602
  return new Loader(this.tui, this.spinnerStyle, this.dimStyle, message);
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Attach-viewer tool definition for `edit`: pi's builtin definition drives
3
+ * all state (preview computation, header, success/error bg), then the diff
4
+ * body is re-skinned Claude-Code-style after every render pass.
5
+ */
6
+ export declare function createCrtrEditToolDefinition(cwd: string): unknown;