@north-light/crouter 0.3.169 → 0.3.171

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 (122) hide show
  1. package/dist/build-root.d.ts +1 -16
  2. package/dist/build-root.js +1 -25
  3. package/dist/builtin-memory/insights/capture.md +66 -0
  4. package/dist/builtin-memory/insights/init.md +45 -0
  5. package/dist/builtin-memory/internal/plugins.md +12 -11
  6. package/dist/cli.js +2 -6
  7. package/dist/clients/attach/__tests__/attach-keybindings.test.js +7 -1
  8. package/dist/clients/attach/__tests__/context-message.test.js +50 -3
  9. package/dist/clients/attach/__tests__/diagram.test.js +1 -1
  10. package/dist/clients/attach/chrome/header.d.ts +10 -0
  11. package/dist/clients/attach/chrome/header.js +35 -0
  12. package/dist/clients/attach/chrome/status-line.d.ts +3 -0
  13. package/dist/clients/attach/chrome/status-line.js +5 -0
  14. package/dist/clients/attach/chrome/widgets.js +7 -18
  15. package/dist/clients/attach/input/controller.d.ts +10 -0
  16. package/dist/clients/attach/input/controller.js +3 -0
  17. package/dist/clients/attach/overlays/help.d.ts +33 -0
  18. package/dist/clients/attach/overlays/help.js +204 -0
  19. package/dist/clients/attach/render/chat-view.d.ts +81 -9
  20. package/dist/clients/attach/render/chat-view.js +434 -90
  21. package/dist/clients/attach/render/context-message.js +3 -3
  22. package/dist/clients/attach/render/diagram.js +2 -2
  23. package/dist/clients/attach/render/measured-container.d.ts +94 -0
  24. package/dist/clients/attach/render/measured-container.js +307 -0
  25. package/dist/clients/attach/render/transcript-copy.d.ts +9 -0
  26. package/dist/clients/attach/render/transcript-copy.js +81 -0
  27. package/dist/clients/attach/render/viewport.d.ts +29 -0
  28. package/dist/clients/attach/render/viewport.js +160 -0
  29. package/dist/clients/attach/session/bindings.d.ts +5 -1
  30. package/dist/clients/attach/session/bindings.js +16 -0
  31. package/dist/clients/attach/session/context.d.ts +5 -3
  32. package/dist/clients/attach/session/frame.d.ts +34 -0
  33. package/dist/clients/attach/session/frame.js +173 -0
  34. package/dist/clients/attach/session/frames.d.ts +2 -0
  35. package/dist/clients/attach/session/frames.js +3 -0
  36. package/dist/clients/attach/session/input-wiring.d.ts +10 -4
  37. package/dist/clients/attach/session/input-wiring.js +13 -13
  38. package/dist/clients/attach/session/keys.d.ts +4 -1
  39. package/dist/clients/attach/session/keys.js +9 -4
  40. package/dist/clients/attach/session/layout.d.ts +10 -8
  41. package/dist/clients/attach/session/layout.js +32 -31
  42. package/dist/clients/attach/session/mouse.d.ts +15 -0
  43. package/dist/clients/attach/session/mouse.js +61 -0
  44. package/dist/clients/attach/slash/dispatch.d.ts +10 -0
  45. package/dist/clients/attach/slash/dispatch.js +35 -2
  46. package/dist/clients/attach/viewer.js +605 -581
  47. package/dist/clients/web/dev-server.d.ts +4 -1
  48. package/dist/clients/web/dev-server.js +8 -19
  49. package/dist/clients/web/web-cmd.js +1 -1
  50. package/dist/commands/human/prompts.js +16 -6
  51. package/dist/commands/pkg/plugin-manage.js +143 -68
  52. package/dist/commands/sys/__tests__/setup-core.test.js +4 -2
  53. package/dist/commands/sys/setup-core.js +21 -16
  54. package/dist/core/__tests__/human-deliver.test.js +62 -2
  55. package/dist/core/clipboard-text.d.ts +8 -0
  56. package/dist/core/clipboard-text.js +18 -0
  57. package/dist/core/command-manifests/registry.d.ts +1 -3
  58. package/dist/core/command-plugins/bundle.d.ts +29 -0
  59. package/dist/core/command-plugins/bundle.js +198 -0
  60. package/dist/core/command-plugins/discovery.d.ts +0 -8
  61. package/dist/core/command-plugins/discovery.js +2 -39
  62. package/dist/core/command-plugins/transport/http-fetch.d.ts +4 -30
  63. package/dist/core/command-plugins/transport/http-fetch.js +17 -95
  64. package/dist/core/command.d.ts +9 -13
  65. package/dist/core/command.js +12 -17
  66. package/dist/core/config.js +2 -1
  67. package/dist/core/keybindings/__tests__/resolve.test.js +4 -0
  68. package/dist/core/keybindings/attach-control.d.ts +3 -0
  69. package/dist/core/keybindings/attach-control.js +1 -0
  70. package/dist/core/keybindings/catalog.d.ts +2 -2
  71. package/dist/core/keybindings/catalog.js +8 -0
  72. package/dist/core/runtime/broker-protocol.d.ts +13 -1
  73. package/dist/core/runtime/broker.js +213 -5
  74. package/dist/core/runtime/canvas-extensions.d.ts +3 -0
  75. package/dist/core/runtime/canvas-extensions.js +3 -0
  76. package/dist/core/runtime/front-door.js +4 -4
  77. package/dist/core/runtime/kickoff.d.ts +1 -5
  78. package/dist/core/runtime/kickoff.js +1 -5
  79. package/dist/core/runtime/naming.d.ts +7 -0
  80. package/dist/core/runtime/naming.js +5 -2
  81. package/dist/core/runtime/node-read.js +15 -0
  82. package/dist/core/runtime/package-health.d.ts +6 -21
  83. package/dist/core/runtime/package-health.js +12 -123
  84. package/dist/core/runtime/stop-guard.d.ts +1 -1
  85. package/dist/core/runtime/stop-guard.js +3 -3
  86. package/dist/core/runtime/tool-group-summary.d.ts +19 -0
  87. package/dist/core/runtime/tool-group-summary.js +117 -0
  88. package/dist/core/termrender/code-doc.d.ts +12 -0
  89. package/dist/core/termrender/code-doc.js +91 -0
  90. package/dist/core/termrender/display.d.ts +12 -0
  91. package/dist/core/termrender/display.js +19 -0
  92. package/dist/core/termrender/termrender.d.ts +73 -0
  93. package/dist/core/termrender/termrender.js +795 -0
  94. package/dist/core/termrender/version.d.ts +1 -0
  95. package/dist/core/termrender/version.js +1 -0
  96. package/dist/core/user-settings.d.ts +14 -0
  97. package/dist/core/user-settings.js +20 -0
  98. package/dist/daemon/crtrd.js +5 -6
  99. package/dist/index.d.ts +2 -0
  100. package/dist/index.js +1 -0
  101. package/dist/pi-extensions/__tests__/canvas-structured-output.test.d.ts +1 -0
  102. package/dist/pi-extensions/__tests__/canvas-structured-output.test.js +63 -0
  103. package/dist/pi-extensions/canvas-stophook.js +2 -2
  104. package/dist/pi-extensions/canvas-structured-output.js +52 -8
  105. package/dist/pi-extensions/summary-tool.d.ts +33 -0
  106. package/dist/pi-extensions/summary-tool.js +62 -0
  107. package/dist/shared/generated-context.d.ts +19 -4
  108. package/dist/shared/generated-context.js +98 -7
  109. package/dist/shared/tool-groups.d.ts +23 -0
  110. package/dist/shared/tool-groups.js +60 -0
  111. package/dist/types.d.ts +11 -0
  112. package/dist/types.js +1 -0
  113. package/dist/web-client/assets/{index-U_NZ66VE.js → index-D7I3gHCL.js} +26 -19
  114. package/dist/web-client/index.html +1 -1
  115. package/dist/web-client/sw.js +1 -1
  116. package/package.json +3 -1
  117. package/runtime.lock.json +62 -3
  118. package/scripts/postinstall.mjs +14 -1
  119. package/dist/core/command-plugins/store.d.ts +0 -16
  120. package/dist/core/command-plugins/store.js +0 -64
  121. package/dist/core/runtime/fault-recovery-nudge.d.ts +0 -3
  122. package/dist/core/runtime/fault-recovery-nudge.js +0 -3
@@ -0,0 +1,160 @@
1
+ /** Exactly one line retained for each clipped row of an inline-image block. */
2
+ const CLIPPED_IMAGE_ROW = '\x1b[2m⌸ image — scroll to view\x1b[22m';
3
+ const NO_VISIBLE_BLOCKS = { first: -1, last: -1, lastFullyVisible: -1 };
4
+ function isInlineImageBlock(lines) {
5
+ return lines.some((line) => line.includes('\x1b_G') || line.includes('\x1b]1337;File='));
6
+ }
7
+ /** The first block whose rendered range extends beyond `line`. */
8
+ function firstBlockAfter(source, line, width) {
9
+ let low = 0;
10
+ let high = source.blockCount();
11
+ while (low < high) {
12
+ const middle = Math.floor((low + high) / 2);
13
+ const end = source.blockStart(middle, width) + source.blockHeight(middle, width);
14
+ if (end > line)
15
+ high = middle;
16
+ else
17
+ low = middle + 1;
18
+ }
19
+ return low;
20
+ }
21
+ export function createTranscriptViewport(source) {
22
+ const state = { follow: true, anchor: undefined };
23
+ let lastWidth;
24
+ let lastRows = 0;
25
+ let cachedVisibleBlocks = NO_VISIBLE_BLOCKS;
26
+ let cachedVisibleRevision;
27
+ /** Resolve the identity anchor only against the source's current measurements. */
28
+ function resolveTop(width, rows, total) {
29
+ const bottom = Math.max(0, total - rows);
30
+ if (state.follow)
31
+ return bottom;
32
+ if (!state.anchor) {
33
+ state.follow = true;
34
+ return bottom;
35
+ }
36
+ const anchoredTop = source.anchorLine(state.anchor, width);
37
+ if (anchoredTop === undefined) {
38
+ state.follow = true;
39
+ state.anchor = undefined;
40
+ return bottom;
41
+ }
42
+ return Math.min(Math.max(0, anchoredTop), bottom);
43
+ }
44
+ function visibleRange(width, rows, total, top) {
45
+ const bottom = Math.min(total, top + rows);
46
+ if (top >= bottom)
47
+ return { top, bottom, total, blocks: NO_VISIBLE_BLOCKS };
48
+ const count = source.blockCount();
49
+ let index = firstBlockAfter(source, top, width);
50
+ let first = -1;
51
+ let last = -1;
52
+ let lastFullyVisible = -1;
53
+ while (index < count) {
54
+ const start = source.blockStart(index, width);
55
+ if (start >= bottom)
56
+ break;
57
+ const height = source.blockHeight(index, width);
58
+ const end = start + height;
59
+ if (end > top) {
60
+ if (first === -1)
61
+ first = index;
62
+ last = index;
63
+ if (start >= top && end <= bottom)
64
+ lastFullyVisible = index;
65
+ }
66
+ index++;
67
+ }
68
+ return { top, bottom, total, blocks: first === -1 ? NO_VISIBLE_BLOCKS : { first, last, lastFullyVisible } };
69
+ }
70
+ function currentSlice() {
71
+ if (lastWidth === undefined || lastRows <= 0)
72
+ return undefined;
73
+ const total = source.totalHeight(lastWidth);
74
+ const top = resolveTop(lastWidth, lastRows, total);
75
+ return visibleRange(lastWidth, lastRows, total, top);
76
+ }
77
+ return {
78
+ renderSlice(width, rows) {
79
+ const viewportRows = Math.max(0, rows);
80
+ lastWidth = width;
81
+ lastRows = viewportRows;
82
+ if (viewportRows === 0) {
83
+ cachedVisibleBlocks = NO_VISIBLE_BLOCKS;
84
+ cachedVisibleRevision = source.revision();
85
+ return [];
86
+ }
87
+ const total = source.totalHeight(width);
88
+ const top = resolveTop(width, viewportRows, total);
89
+ const slice = visibleRange(width, viewportRows, total, top);
90
+ const content = [];
91
+ for (let index = slice.blocks.first; index !== -1 && index <= slice.blocks.last; index++) {
92
+ const start = source.blockStart(index, width);
93
+ const lines = source.blockLines(index, width);
94
+ const height = source.blockHeight(index, width);
95
+ const end = start + height;
96
+ const visibleStart = Math.max(0, slice.top - start);
97
+ const visibleEnd = Math.min(height, slice.bottom - start);
98
+ if (visibleStart >= visibleEnd)
99
+ continue;
100
+ const partiallyClipped = start < slice.top || end > slice.bottom;
101
+ if (partiallyClipped && isInlineImageBlock(lines)) {
102
+ content.push(...Array(visibleEnd - visibleStart).fill(CLIPPED_IMAGE_ROW));
103
+ }
104
+ else {
105
+ content.push(...lines.slice(visibleStart, visibleEnd));
106
+ }
107
+ }
108
+ const padded = content.length >= viewportRows
109
+ ? content.slice(0, viewportRows)
110
+ : [...Array(viewportRows - content.length).fill(''), ...content];
111
+ cachedVisibleBlocks = slice.blocks;
112
+ cachedVisibleRevision = source.revision();
113
+ return padded;
114
+ },
115
+ scrollBy(lines) {
116
+ if (lastWidth === undefined || lastRows <= 0 || lines === 0)
117
+ return false;
118
+ const beforeFollow = state.follow;
119
+ const beforeAnchor = state.anchor;
120
+ const total = source.totalHeight(lastWidth);
121
+ const currentTop = resolveTop(lastWidth, lastRows, total);
122
+ const bottom = Math.max(0, total - lastRows);
123
+ const newTop = Math.min(Math.max(0, currentTop + lines), bottom);
124
+ if (newTop >= bottom) {
125
+ state.follow = true;
126
+ state.anchor = undefined;
127
+ }
128
+ else {
129
+ state.follow = false;
130
+ state.anchor = source.anchorAt(newTop, lastWidth);
131
+ if (!state.anchor)
132
+ state.follow = true;
133
+ }
134
+ return state.follow !== beforeFollow || state.anchor !== beforeAnchor;
135
+ },
136
+ scrollPage(direction) {
137
+ const page = Math.max(0, lastRows - 1);
138
+ return this.scrollBy(direction === 'up' ? -page : page);
139
+ },
140
+ goLive() {
141
+ state.follow = true;
142
+ state.anchor = undefined;
143
+ },
144
+ isLive() {
145
+ return state.follow;
146
+ },
147
+ visibleBlocks() {
148
+ const revision = source.revision();
149
+ if (cachedVisibleRevision === revision)
150
+ return cachedVisibleBlocks;
151
+ const slice = currentSlice();
152
+ cachedVisibleBlocks = slice?.blocks ?? NO_VISIBLE_BLOCKS;
153
+ cachedVisibleRevision = revision;
154
+ return cachedVisibleBlocks;
155
+ },
156
+ invalidate() {
157
+ source.invalidate();
158
+ },
159
+ };
160
+ }
@@ -2,10 +2,14 @@ import { type AttachBindingId, type BindingId, type BindingResolution } from '..
2
2
  export type { AttachBindingId } from '../../../core/keybindings/index.js';
3
3
  export type AttachBindings = BindingResolution<BindingId>;
4
4
  export type AttachActions = Record<AttachBindingId, () => void>;
5
+ export declare const ATTACH_VIEWPORT_BINDINGS: readonly ["crtr.attach.scroll.up", "crtr.attach.scroll.down", "crtr.attach.scroll.page-up", "crtr.attach.scroll.page-down", "crtr.attach.scroll.live", "crtr.attach.mouse.toggle", "crtr.attach.copy.message"];
6
+ export type AttachViewportBindingId = typeof ATTACH_VIEWPORT_BINDINGS[number];
7
+ export type AttachViewportActions = Record<AttachViewportBindingId, () => void>;
5
8
  export { inboxOpenHint } from '../../../core/keybindings/index.js';
6
- export declare function matchesAttachBinding(bindings: AttachBindings, id: AttachBindingId, data: string): boolean;
9
+ export declare function matchesAttachBinding(bindings: AttachBindings, id: BindingId, data: string): boolean;
7
10
  export declare function dispatchAttachAction(id: AttachBindingId, actions: AttachActions): void;
8
11
  export declare function dispatchAttachBinding(bindings: AttachBindings, data: string, actions: AttachActions): boolean;
12
+ export declare function dispatchAttachViewportBinding(bindings: AttachBindings, data: string, actions: AttachViewportActions): boolean;
9
13
  export declare function detachHint(bindings: AttachBindings): string | undefined;
10
14
  /** Re-resolve the user's binding catalog and republish it to every consumer:
11
15
  * the graph overlay's own snapshot and (in tmux) the crouter-owned tmux
@@ -6,6 +6,13 @@
6
6
  // literals, so a user's rebind is honored by the viewer and tmux alike.
7
7
  import { matchesKey } from '@earendil-works/pi-tui';
8
8
  import { ATTACH_CONTROL_BINDINGS, formatBinding, matchesPiTuiInput, } from '../../../core/keybindings/index.js';
9
+ export const ATTACH_VIEWPORT_BINDINGS = [
10
+ 'crtr.attach.scroll.up', 'crtr.attach.scroll.down',
11
+ 'crtr.attach.scroll.page-up', 'crtr.attach.scroll.page-down',
12
+ 'crtr.attach.scroll.live',
13
+ 'crtr.attach.mouse.toggle',
14
+ 'crtr.attach.copy.message',
15
+ ];
9
16
  export { inboxOpenHint } from '../../../core/keybindings/index.js';
10
17
  export function matchesAttachBinding(bindings, id, data) {
11
18
  return matchesPiTuiInput(bindings, id, data, (input, gesture) => matchesKey(input, gesture));
@@ -22,6 +29,15 @@ export function dispatchAttachBinding(bindings, data, actions) {
22
29
  }
23
30
  return false;
24
31
  }
32
+ export function dispatchAttachViewportBinding(bindings, data, actions) {
33
+ for (const actionId of ATTACH_VIEWPORT_BINDINGS) {
34
+ if (matchesAttachBinding(bindings, actionId, data)) {
35
+ actions[actionId]();
36
+ return true;
37
+ }
38
+ }
39
+ return false;
40
+ }
25
41
  export function detachHint(bindings) {
26
42
  for (const id of ['crtr.attach.clear-or-detach', 'crtr.attach.detach']) {
27
43
  if (bindings.gestures(id).length > 0)
@@ -5,12 +5,14 @@ import type { NodeMeta } from '../../../core/canvas/types.js';
5
5
  import type { BrokerClient } from '../../../core/broker-client/index.js';
6
6
  import type { AttachPalette } from '../config.js';
7
7
  import type { TitledEditor } from '../input/titled-editor.js';
8
- /** The bottom-anchored chrome stack plus the scrolling chat, in render order.
8
+ /** The pinned cockpit plus transcript/header regions, in frame render order.
9
9
  * Owned by the layout step; every painter writes into exactly one of these. */
10
10
  export interface AttachContainers {
11
- /** Scrolling transcript — the only container above the chat/chrome divider. */
11
+ /** Scrolling transcript source, rendered by the virtualized viewport. */
12
12
  readonly chat: Container;
13
- /** The themed divider itself (suppressed while the recap widget is present). */
13
+ /** Pinned recap above the transcript; empty when no recap widget exists. */
14
+ readonly header: Container;
15
+ /** The themed transcript/cockpit divider. */
14
16
  readonly chrome: Container;
15
17
  /** Extension widgets placed `aboveEditor`, including the inactivity brief. */
16
18
  readonly aboveWidgets: Container;
@@ -0,0 +1,34 @@
1
+ import { type Component, type Terminal } from '@earendil-works/pi-tui';
2
+ import type { TranscriptViewport } from '../render/viewport.js';
3
+ export declare const MIN_TRANSCRIPT_ROWS = 3;
4
+ export declare const HEADER_MAX_ROWS = 6;
5
+ export declare const EDITOR_MIN_ROWS = 1;
6
+ export declare const STATUS_ROWS = 1;
7
+ export type BottomSlot = 'chrome' | 'queued' | 'aboveWidgets' | 'editor' | 'pickerPanel' | 'belowWidgets' | 'reports' | 'footer';
8
+ /** Crop `lines` to `alloc` rows, guaranteeing the CURSOR_MARKER row survives
9
+ * (§2.1). No marker → the tail is kept. */
10
+ export declare function cropAroundCursor(lines: readonly string[], alloc: number): string[];
11
+ export interface ViewerFrameParts {
12
+ /** Rendered above the transcript; 0 rows when empty. */
13
+ header: Component;
14
+ viewport: TranscriptViewport;
15
+ /** The bottom slots, in VISUAL order, tagged with their allocation identity
16
+ * so the allocator can apply the §2.1 priority table. */
17
+ bottom: readonly {
18
+ slot: BottomSlot;
19
+ component: Component;
20
+ }[];
21
+ }
22
+ /** The TUI's ONE child. Renders exactly `terminal.rows` lines, every frame. */
23
+ export declare class ViewerFrame implements Component {
24
+ private readonly terminal;
25
+ private readonly parts;
26
+ private viewportRows;
27
+ constructor(terminal: Terminal, parts: ViewerFrameParts);
28
+ render(width: number): string[];
29
+ invalidate(): void;
30
+ /** Rows the transcript viewport got in the last frame — the page size. */
31
+ lastViewportRows(): number;
32
+ /** Retain exactly one editor marker if the focused editor rendered one. */
33
+ private ensureCursorMarker;
34
+ }
@@ -0,0 +1,173 @@
1
+ // session/frame.ts — the attach viewer's single full-pane TUI child.
2
+ //
3
+ // The frame owns the pane rectangle: transcript rows are virtualized between a
4
+ // bounded recap header and a cursor-first cockpit. Nothing mounted beneath this
5
+ // component may reach pi-tui as a separate child, or it can reintroduce terminal
6
+ // scrollback and lose the editor's hardware cursor.
7
+ import { CURSOR_MARKER } from '@earendil-works/pi-tui';
8
+ export const MIN_TRANSCRIPT_ROWS = 3;
9
+ export const HEADER_MAX_ROWS = 6;
10
+ export const EDITOR_MIN_ROWS = 1;
11
+ export const STATUS_ROWS = 1;
12
+ /** Crop `lines` to `alloc` rows, guaranteeing the CURSOR_MARKER row survives
13
+ * (§2.1). No marker → the tail is kept. */
14
+ export function cropAroundCursor(lines, alloc) {
15
+ const rows = Math.max(0, Math.floor(alloc));
16
+ if (lines.length <= rows)
17
+ return lines.slice();
18
+ if (rows === 0)
19
+ return [];
20
+ const marker = lines.findIndex((line) => line.includes(CURSOR_MARKER));
21
+ if (marker === -1)
22
+ return lines.slice(-rows);
23
+ const start = clamp(marker - Math.floor((rows - 1) / 2), 0, lines.length - rows);
24
+ const cropped = lines.slice(start, start + rows);
25
+ if (!cropped.some((line) => line.includes(CURSOR_MARKER))) {
26
+ // This should be unreachable for a positive allocation. Keep the cursor
27
+ // row rather than silently making pi-tui lose the hardware cursor.
28
+ reportInvariant('cursor crop discarded CURSOR_MARKER');
29
+ return [lines[marker]];
30
+ }
31
+ return cropped;
32
+ }
33
+ const SLOT_PRIORITY = [
34
+ 'queued',
35
+ 'aboveWidgets',
36
+ 'belowWidgets',
37
+ 'reports',
38
+ 'chrome',
39
+ ];
40
+ const SLOT_VISUAL_ORDER = [
41
+ 'chrome',
42
+ 'queued',
43
+ 'aboveWidgets',
44
+ 'editor',
45
+ 'pickerPanel',
46
+ 'belowWidgets',
47
+ 'reports',
48
+ 'footer',
49
+ ];
50
+ /** The TUI's ONE child. Renders exactly `terminal.rows` lines, every frame. */
51
+ export class ViewerFrame {
52
+ terminal;
53
+ parts;
54
+ viewportRows = 0;
55
+ constructor(terminal, parts) {
56
+ this.terminal = terminal;
57
+ this.parts = parts;
58
+ }
59
+ render(width) {
60
+ const rows = Math.max(0, this.terminal.rows);
61
+ const rendered = new Map();
62
+ for (const { slot, component } of this.parts.bottom)
63
+ rendered.set(slot, component.render(width));
64
+ const allocations = new Map();
65
+ const linesFor = (slot) => rendered.get(slot) ?? [];
66
+ const allocate = (slot, lines) => {
67
+ allocations.set(slot, lines);
68
+ };
69
+ let remaining = Math.min(rows, Math.max(EDITOR_MIN_ROWS + STATUS_ROWS, rows - MIN_TRANSCRIPT_ROWS));
70
+ const footerLines = linesFor('footer');
71
+ const statusAlloc = footerLines.length > 0 && remaining > EDITOR_MIN_ROWS ? STATUS_ROWS : 0;
72
+ allocate('footer', statusAlloc === 0 ? [] : footerLines.slice(-statusAlloc));
73
+ remaining -= statusAlloc;
74
+ const pickerLines = linesFor('pickerPanel');
75
+ const pickerReserve = pickerLines.length === 0 ? 0 : Math.min(pickerLines.length, Math.floor(remaining / 2));
76
+ const editorLines = linesFor('editor');
77
+ const editorAlloc = clamp(editorLines.length, EDITOR_MIN_ROWS, Math.max(EDITOR_MIN_ROWS, remaining - pickerReserve));
78
+ const editorSlice = cropAroundCursor(editorLines, editorAlloc);
79
+ allocate('editor', editorSlice);
80
+ remaining -= editorAlloc;
81
+ const pickerAlloc = Math.min(pickerLines.length, remaining);
82
+ allocate('pickerPanel', pickerLines.slice(0, pickerAlloc));
83
+ remaining -= pickerAlloc;
84
+ for (const slot of SLOT_PRIORITY) {
85
+ const lines = linesFor(slot);
86
+ if (lines.length <= remaining) {
87
+ allocate(slot, lines);
88
+ remaining -= lines.length;
89
+ }
90
+ else {
91
+ allocate(slot, []);
92
+ }
93
+ }
94
+ const bottomLines = SLOT_VISUAL_ORDER.flatMap((slot) => allocations.get(slot) ?? []);
95
+ const editorBottomOffset = SLOT_VISUAL_ORDER
96
+ .slice(0, SLOT_VISUAL_ORDER.indexOf('editor'))
97
+ .reduce((count, slot) => count + (allocations.get(slot)?.length ?? 0), 0);
98
+ let headerLines = this.parts.header.render(width).slice(0, HEADER_MAX_ROWS);
99
+ if (rows - bottomLines.length - headerLines.length < MIN_TRANSCRIPT_ROWS)
100
+ headerLines = [];
101
+ const viewportRows = Math.max(0, rows - headerLines.length - bottomLines.length);
102
+ this.viewportRows = viewportRows;
103
+ let viewportLines = this.parts.viewport.renderSlice(width, viewportRows);
104
+ let frame = [...headerLines, ...viewportLines, ...bottomLines];
105
+ // A misbehaving viewport is repaired in its own region only: transcript
106
+ // padding goes directly beneath the header, while excess drops from its top.
107
+ // The cockpit, including the editor's marker row, is never sliced here.
108
+ if (frame.length !== rows) {
109
+ reportInvariant(`frame rendered ${frame.length} rows; expected ${rows}`);
110
+ viewportLines = fitTranscript(viewportLines, viewportRows);
111
+ frame = [...headerLines, ...viewportLines, ...bottomLines];
112
+ }
113
+ if (frame.length !== rows)
114
+ reportInvariant(`frame correction rendered ${frame.length} rows; expected ${rows}`);
115
+ return this.ensureCursorMarker(frame, editorLines, editorSlice, editorBottomOffset, bottomLines.length, rows);
116
+ }
117
+ invalidate() {
118
+ this.parts.header.invalidate();
119
+ for (const { component } of this.parts.bottom)
120
+ component.invalidate();
121
+ this.parts.viewport.invalidate();
122
+ }
123
+ /** Rows the transcript viewport got in the last frame — the page size. */
124
+ lastViewportRows() {
125
+ return this.viewportRows;
126
+ }
127
+ /** Retain exactly one editor marker if the focused editor rendered one. */
128
+ ensureCursorMarker(frame, editorLines, editorSlice, editorBottomOffset, bottomRows, rows) {
129
+ const editorMarker = editorLines.find((line) => line.includes(CURSOR_MARKER));
130
+ const markerCount = countMarkers(frame);
131
+ if (editorMarker === undefined ? markerCount <= 1 : markerCount === 1)
132
+ return frame;
133
+ reportInvariant(editorMarker === undefined
134
+ ? `frame contains ${markerCount} CURSOR_MARKER sequences without one from the editor`
135
+ : `frame contains ${markerCount} CURSOR_MARKER sequences; expected exactly one`);
136
+ const repaired = frame.map(stripCursorMarkers);
137
+ if (editorMarker === undefined)
138
+ return repaired;
139
+ const markerLine = editorSlice.findIndex((line) => line.includes(CURSOR_MARKER));
140
+ const frameIndex = rows - bottomRows + editorBottomOffset + markerLine;
141
+ if (markerLine === -1 || frameIndex < 0 || frameIndex >= repaired.length) {
142
+ reportInvariant('unable to restore editor CURSOR_MARKER in composed frame');
143
+ return repaired;
144
+ }
145
+ const markerAt = editorSlice[markerLine].indexOf(CURSOR_MARKER);
146
+ const line = repaired[frameIndex];
147
+ repaired[frameIndex] = `${line.slice(0, markerAt)}${CURSOR_MARKER}${line.slice(markerAt)}`;
148
+ return repaired;
149
+ }
150
+ }
151
+ function clamp(value, min, max) {
152
+ return Math.min(max, Math.max(min, value));
153
+ }
154
+ /** Correct only the viewport allocation: top-pad short content or retain its tail. */
155
+ function fitTranscript(lines, rows) {
156
+ if (rows === 0)
157
+ return [];
158
+ if (lines.length === rows)
159
+ return lines.slice();
160
+ if (lines.length > rows)
161
+ return lines.slice(-rows);
162
+ return [...Array(rows - lines.length).fill(''), ...lines];
163
+ }
164
+ function countMarkers(lines) {
165
+ return lines.reduce((count, line) => count + line.split(CURSOR_MARKER).length - 1, 0);
166
+ }
167
+ function stripCursorMarkers(line) {
168
+ return line.split(CURSOR_MARKER).join('');
169
+ }
170
+ /** Invariants must surface in development without taking down an attached pane. */
171
+ function reportInvariant(message) {
172
+ console.error(`[attach frame] ${message}`);
173
+ }
@@ -2,6 +2,7 @@ import type { AgentSessionEvent } from '@earendil-works/pi-coding-agent';
2
2
  import type { BrokerToClient, RpcExtensionUIRequest } from '../../../core/runtime/broker-protocol.js';
3
3
  import { type PreviewableCommand } from '../slash/prompt-preview.js';
4
4
  import type { AttachSession } from './context.js';
5
+ import type { ToolGroupSummary } from '../../../shared/tool-groups.js';
5
6
  export interface FrameEffects {
6
7
  /** Fold the frame through the shared reducer and repaint whatever slice of
7
8
  * chrome changed. Runs FIRST for every frame, before any case below. */
@@ -18,6 +19,7 @@ export interface FrameEffects {
18
19
  bashEnd: (frame: Extract<BrokerToClient, {
19
20
  type: 'bash_end';
20
21
  }>) => void;
22
+ applyToolGroupSummary: (key: string, summary: ToolGroupSummary) => void;
21
23
  };
22
24
  setNotice: (message: string) => void;
23
25
  /** Repaint the status line — for the frames whose footer effect is NOT
@@ -127,6 +127,9 @@ export function createFrameHandler(ctx, fx) {
127
127
  fx.attachDialog(frame.pending_dialog);
128
128
  break;
129
129
  }
130
+ case 'tool_group_summary':
131
+ fx.chat.applyToolGroupSummary(frame.key, frame.summary);
132
+ break;
130
133
  case 'model_changed': {
131
134
  // The broker's own post-set_model/cycle_model announcement (pi emits no
132
135
  // engine event for a model switch) already moved the footer's model
@@ -1,5 +1,7 @@
1
1
  import { InputController } from '../input/controller.js';
2
+ import type { ToolDisplayToggle } from '../render/chat-view.js';
2
3
  import type { KeybindingsManager } from '@earendil-works/pi-tui';
4
+ import type { CopyTarget } from '../render/transcript-copy.js';
3
5
  import type { AttachSession } from './context.js';
4
6
  /** What the input layer needs from the rest of the session. Everything here is
5
7
  * a getter or a command — never a value — so nothing can go stale. */
@@ -11,10 +13,10 @@ export interface InputWiringHooks {
11
13
  agentDir: () => string | undefined;
12
14
  /** True while the reconnect supervisor has classified the node as dormant. */
13
15
  isDormant: () => boolean;
14
- /** The last assistant message, for `/copy` (ChatView holds it). */
15
- lastAssistantText: () => string | undefined;
16
- /** Ctrl+O — flip global tool-output expansion; returns the new state. */
17
- toggleToolsExpanded: () => boolean;
16
+ /** Copy a viewer-local transcript target and surface its confirmation. */
17
+ copyTarget: (target: CopyTarget) => void;
18
+ /** Ctrl+O — toggles either collapsed summaries or tool-output expansion. */
19
+ toggleToolsExpanded: () => ToolDisplayToggle;
18
20
  /** `/fold-tools` — flip the fold-finished-tool-calls view setting; returns the
19
21
  * new state. */
20
22
  toggleFoldTools: () => boolean;
@@ -22,6 +24,10 @@ export interface InputWiringHooks {
22
24
  toggleThinking: () => boolean;
23
25
  /** `/graph` and the graph keybinding. */
24
26
  toggleGraph: () => void;
27
+ /** `/hotkeys` and the help keybinding. */
28
+ toggleHelp: () => void;
29
+ /** `/mouse` — flip mouse wheel reporting and return the new state. */
30
+ toggleMouse: () => boolean;
25
31
  /** `/node-metadata` — open the viewer-owned Inspector dossier popup. */
26
32
  openNodeMetadata: () => void;
27
33
  /** `/quit` — detach this viewer; the engine keeps running. Must tear down
@@ -13,7 +13,6 @@
13
13
  // can only be closed over from inside).
14
14
  import { InputController } from '../input/controller.js';
15
15
  import { cliClient } from '../../../commands/api-client.js';
16
- import { writeClipboardText } from '../../../core/clipboard-text.js';
17
16
  import { buildLoginPicker, buildLogoutPicker } from '../overlays/auth.js';
18
17
  import { defaultAgentDir } from '../config.js';
19
18
  /** Build the viewer's InputController with every hook answered. */
@@ -25,16 +24,6 @@ export function createInputController(ctx, km, hooks) {
25
24
  // mutate. Every mutating hook below is wired only when this is true; the
26
25
  // read-op channel (`onRequest`) and the read-only getters stay wired for all.
27
26
  const writable = !remote && role === 'controller';
28
- // `/copy` — the last assistant message (ChatView keeps it current from both
29
- // the welcome snapshot and the live stream) → the system clipboard.
30
- const copyLastAssistant = () => {
31
- const text = hooks.lastAssistantText();
32
- if (!text) {
33
- setNotice('Nothing to copy — no assistant message yet');
34
- return;
35
- }
36
- setNotice(writeClipboardText(text) ? 'Copied last message to clipboard' : 'Copy failed — no clipboard tool found');
37
- };
38
27
  // Auth pickers — viewer-local OAuth/API-key flows against the SAME auth.json
39
28
  // the broker uses (broker + viewer share a filesystem in the tmux-local attach
40
29
  // model), so they run entirely in this process and then tell the broker to
@@ -72,14 +61,25 @@ export function createInputController(ctx, km, hooks) {
72
61
  remote,
73
62
  role,
74
63
  onGraph: hooks.toggleGraph,
64
+ onHelp: hooks.toggleHelp,
75
65
  onNodeMetadata: hooks.openNodeMetadata,
76
66
  onQuit: hooks.quit,
77
- onCopy: copyLastAssistant,
67
+ onCopy: () => hooks.copyTarget('last-assistant'),
68
+ onCopyTarget: hooks.copyTarget,
69
+ onToggleMouse: () => {
70
+ const on = hooks.toggleMouse();
71
+ setNotice(on ? 'Mouse: wheel scrolling on' : 'Mouse: off — tmux drag-select and copy-mode restored');
72
+ },
78
73
  onColor: hooks.setChipColor,
79
74
  // Global display toggles (Ctrl+O tools / Ctrl+T thinking) — pure render
80
75
  // state owned by ChatView, surfaced as a footer notice mirroring pi's
81
76
  // status line.
82
- onToggleToolsExpand: () => setNotice(hooks.toggleToolsExpanded() ? 'Tool output: expanded' : 'Tool output: collapsed'),
77
+ onToggleToolsExpand: () => {
78
+ const result = hooks.toggleToolsExpanded();
79
+ setNotice(result.kind === 'summaries'
80
+ ? result.shown ? 'Tool calls: summarized' : 'Tool calls: shown'
81
+ : result.expanded ? 'Tool output: expanded' : 'Tool output: collapsed');
82
+ },
83
83
  onFoldTools: () => setNotice(hooks.toggleFoldTools()
84
84
  ? 'Finished tool calls: folded to one line'
85
85
  : 'Finished tool calls: shown in full'),
@@ -1,4 +1,4 @@
1
- import { type AttachBindings } from './bindings.js';
1
+ import { type AttachBindings, type AttachViewportActions } from './bindings.js';
2
2
  import type { AttachSession } from './context.js';
3
3
  export interface KeyHooks {
4
4
  /** The live binding snapshot — re-resolved by `/reload`, so read per key. */
@@ -7,12 +7,15 @@ export interface KeyHooks {
7
7
  detachHint: () => string | undefined;
8
8
  /** Terminal focus in/out — the roster owns the editor's cursor styling. */
9
9
  setPaneFocused: (focused: boolean) => void;
10
+ /** Scroll actions for this session's transcript viewport. */
11
+ viewportActions: () => AttachViewportActions;
10
12
  /** The roster's chance at a chunk; true when it consumed it. */
11
13
  rosterHandleKey: (data: string) => boolean;
12
14
  setNotice: (message: string) => void;
13
15
  detach: () => void;
14
16
  cycleMode: () => void;
15
17
  toggleGraph: () => void;
18
+ toggleHelp: () => void;
16
19
  cycleModelLadder: (direction: 'forward' | 'backward') => void;
17
20
  inspectLoadedCommand: () => void;
18
21
  /** The file-review chord — open the transcript-file picker + review surface. */
@@ -3,11 +3,12 @@
3
3
  // A TUI input listener fires BEFORE the focused component and before any dialog
4
4
  // overlay, so the configured attach actions work regardless of what has focus.
5
5
  // That privilege is also the hazard: anything consumed here never reaches the
6
- // editor. Only terminal focus reports, a private crouter control input, the
7
- // roster while it holds the cursor, and a configured crtr binding may claim a
8
- // chunk; each returns ordinary editor input untouched when it does not apply.
6
+ // editor. Only terminal focus reports, a private crouter control input,
7
+ // viewport scroll bindings while the editor has focus, the roster while it holds
8
+ // the cursor, and a configured crtr binding may claim a chunk; each returns
9
+ // ordinary editor input untouched when it does not apply.
9
10
  import { extractAttachControlInput } from '../../../core/keybindings/index.js';
10
- import { dispatchAttachAction, dispatchAttachBinding } from './bindings.js';
11
+ import { dispatchAttachAction, dispatchAttachBinding, dispatchAttachViewportBinding, } from './bindings.js';
11
12
  import { FOCUS_IN, FOCUS_OUT } from './focus-events.js';
12
13
  /** Press the clear-or-detach binding twice inside this window to detach. */
13
14
  const CLEAR_OR_DETACH_MS = 1000;
@@ -31,6 +32,7 @@ export function installGlobalKeys(ctx, hooks) {
31
32
  },
32
33
  'crtr.mode.cycle': hooks.cycleMode,
33
34
  'crtr.attach.graph.toggle': hooks.toggleGraph,
35
+ 'crtr.attach.help.toggle': hooks.toggleHelp,
34
36
  // A plain broker frame, so this works locally and remotely alike; the broker gates observers on controller role.
35
37
  'crtr.attach.model-ladder.next': () => hooks.cycleModelLadder('forward'),
36
38
  'crtr.attach.model-ladder.previous': () => hooks.cycleModelLadder('backward'),
@@ -51,6 +53,9 @@ export function installGlobalKeys(ctx, hooks) {
51
53
  dispatchAttachAction(control.actionId, actions);
52
54
  return control.remaining.length > 0 ? { data: control.remaining } : { consume: true };
53
55
  }
56
+ // Let focused pickers and overlays retain their native page navigation.
57
+ if (editor.focused && dispatchAttachViewportBinding(hooks.bindings(), data, hooks.viewportActions()))
58
+ return { consume: true };
54
59
  // Inline subagent scroll+swap: once the editor hands off at its ↓ boundary, ↑/↓ move over the manager/report roster and Enter dives into a node. An inactive roster never intercepts editor input.
55
60
  if (hooks.rosterHandleKey(data))
56
61
  return { consume: true };
@@ -1,8 +1,9 @@
1
- import { ProcessTerminal, TUI } from '@earendil-works/pi-tui';
2
- import type { KeybindingsManager } from '@earendil-works/pi-tui';
1
+ import { Container, type KeybindingsManager, type ProcessTerminal, type TUI } from '@earendil-works/pi-tui';
3
2
  import type { AttachPalette } from '../config.js';
4
3
  import { TitledEditor } from '../input/titled-editor.js';
4
+ import type { TranscriptViewport } from '../render/viewport.js';
5
5
  import type { AttachContainers } from './context.js';
6
+ import { ViewerFrame } from './frame.js';
6
7
  export interface AttachLayout {
7
8
  /** The process terminal behind the TUI — also the owner of the window title
8
9
  * and the negotiated keyboard protocol, both of which the orchestrator uses. */
@@ -10,11 +11,12 @@ export interface AttachLayout {
10
11
  readonly tui: TUI;
11
12
  readonly containers: AttachContainers;
12
13
  readonly editor: TitledEditor;
13
- /** Attach the containers to the TUI in render order and focus the editor.
14
- * Deferred to just before `tui.start()` so a signal landing mid-construction
15
- * cannot leave a half-mounted tree on screen. */
14
+ readonly frame: ViewerFrame;
15
+ /** Attach the single frame to the TUI and focus the editor. Deferred to just
16
+ * before `tui.start()` so a signal landing mid-construction cannot leave a
17
+ * half-mounted tree on screen. */
16
18
  mount(): void;
17
19
  }
18
- /** Build the TUI, its containers, and the editor. No behavior is wired here;
19
- * the orchestrator owns every callback. */
20
- export declare function createLayout(pal: AttachPalette, km: KeybindingsManager): AttachLayout;
20
+ /** Build the layout around the transcript viewport. The viewer creates the
21
+ * terminal and chat first because the viewport measures that chat source. */
22
+ export declare function createLayout(procTerminal: ProcessTerminal, tui: TUI, chat: Container, pal: AttachPalette, km: KeybindingsManager, viewport: TranscriptViewport): AttachLayout;