@aragon-agent/cli 0.6.3 → 0.6.7

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 (138) hide show
  1. package/CHANGELOG.md +142 -0
  2. package/README.md +203 -13
  3. package/dist/agent/controller.d.ts +158 -2
  4. package/dist/agent/controller.js +400 -1
  5. package/dist/agent/reducer.d.ts +102 -19
  6. package/dist/agent/reducer.js +162 -71
  7. package/dist/agent/system-prompt.d.ts +14 -0
  8. package/dist/agent/system-prompt.js +2 -0
  9. package/dist/agent/usage.d.ts +34 -0
  10. package/dist/agent/usage.js +43 -0
  11. package/dist/cli.js +132 -23
  12. package/dist/commands/builtins.js +101 -1
  13. package/dist/compaction/child.js +5 -0
  14. package/dist/compaction/command.js +14 -1
  15. package/dist/compaction/compactor.d.ts +94 -24
  16. package/dist/compaction/compactor.js +247 -98
  17. package/dist/compaction/context-command.d.ts +33 -0
  18. package/dist/compaction/context-command.js +166 -0
  19. package/dist/compaction/meter.d.ts +251 -0
  20. package/dist/compaction/meter.js +428 -0
  21. package/dist/compaction/pressure.d.ts +6 -0
  22. package/dist/compaction/pressure.js +4 -0
  23. package/dist/compaction/types.d.ts +77 -0
  24. package/dist/compaction/wiring.d.ts +56 -0
  25. package/dist/compaction/wiring.js +107 -25
  26. package/dist/config/cli-commands.js +5 -0
  27. package/dist/config/env.d.ts +1 -1
  28. package/dist/config/env.js +63 -2
  29. package/dist/config/load.d.ts +28 -0
  30. package/dist/config/load.js +83 -1
  31. package/dist/config/schema.d.ts +129 -0
  32. package/dist/config/schema.js +69 -0
  33. package/dist/config/store.js +47 -1
  34. package/dist/exec/events.d.ts +138 -1
  35. package/dist/exec/events.js +42 -0
  36. package/dist/exec/index.js +43 -6
  37. package/dist/exec/progress-reporter.d.ts +23 -0
  38. package/dist/exec/progress-reporter.js +88 -0
  39. package/dist/exec/runner.d.ts +34 -0
  40. package/dist/exec/runner.js +137 -25
  41. package/dist/fast/reviewer.d.ts +19 -1
  42. package/dist/fast/reviewer.js +22 -0
  43. package/dist/fast/types.d.ts +15 -0
  44. package/dist/fast/wiring.d.ts +10 -1
  45. package/dist/fast/wiring.js +18 -1
  46. package/dist/input/limits.d.ts +72 -0
  47. package/dist/input/limits.js +63 -0
  48. package/dist/input/paste-parse.d.ts +71 -0
  49. package/dist/input/paste-parse.js +95 -0
  50. package/dist/input/stdin-filter.d.ts +81 -0
  51. package/dist/input/stdin-filter.js +379 -0
  52. package/dist/logging/install.d.ts +2 -0
  53. package/dist/logging/install.js +53 -1
  54. package/dist/proc/classify.d.ts +52 -0
  55. package/dist/proc/classify.js +143 -0
  56. package/dist/proc/kill-tree.d.ts +42 -0
  57. package/dist/proc/kill-tree.js +119 -0
  58. package/dist/proc/limits.d.ts +84 -0
  59. package/dist/proc/limits.js +84 -0
  60. package/dist/proc/log-ring.d.ts +68 -0
  61. package/dist/proc/log-ring.js +102 -0
  62. package/dist/proc/prompt.d.ts +24 -0
  63. package/dist/proc/prompt.js +49 -0
  64. package/dist/proc/readiness.d.ts +102 -0
  65. package/dist/proc/readiness.js +219 -0
  66. package/dist/proc/supervisor.d.ts +103 -0
  67. package/dist/proc/supervisor.js +465 -0
  68. package/dist/proc/types.d.ts +175 -0
  69. package/dist/proc/types.js +15 -0
  70. package/dist/session/persist.js +36 -1
  71. package/dist/todo/limits.d.ts +4 -17
  72. package/dist/todo/limits.js +4 -17
  73. package/dist/todo/panel-rows.d.ts +8 -6
  74. package/dist/todo/panel-rows.js +19 -6
  75. package/dist/tools/bash-tool.d.ts +51 -2
  76. package/dist/tools/bash-tool.js +321 -51
  77. package/dist/tools/fs-tools.d.ts +17 -0
  78. package/dist/tools/index.d.ts +55 -2
  79. package/dist/tools/index.js +39 -1
  80. package/dist/tools/proc-tools.d.ts +25 -0
  81. package/dist/tools/proc-tools.js +102 -0
  82. package/dist/ui/App.d.ts +15 -2
  83. package/dist/ui/App.js +330 -69
  84. package/dist/ui/AutocompletePopup.d.ts +3 -1
  85. package/dist/ui/AutocompletePopup.js +9 -11
  86. package/dist/ui/BottomStatusRow.d.ts +1 -1
  87. package/dist/ui/Composer.d.ts +56 -1
  88. package/dist/ui/Composer.js +50 -6
  89. package/dist/ui/PromptInput.d.ts +52 -3
  90. package/dist/ui/PromptInput.js +201 -55
  91. package/dist/ui/StatusBar.d.ts +40 -15
  92. package/dist/ui/StatusBar.js +51 -9
  93. package/dist/ui/TeamPanel.d.ts +3 -1
  94. package/dist/ui/TeamPanel.js +13 -9
  95. package/dist/ui/TodoPanel.d.ts +5 -5
  96. package/dist/ui/TodoPanel.js +56 -24
  97. package/dist/ui/Transcript.js +29 -1
  98. package/dist/ui/UpdateLine.d.ts +1 -1
  99. package/dist/ui/capabilities.js +5 -5
  100. package/dist/ui/composer-limits.d.ts +31 -0
  101. package/dist/ui/composer-limits.js +50 -0
  102. package/dist/ui/composer-rows.d.ts +73 -0
  103. package/dist/ui/composer-rows.js +201 -0
  104. package/dist/ui/editor-reducer.d.ts +37 -0
  105. package/dist/ui/editor-reducer.js +88 -12
  106. package/dist/ui/entries/ServiceCard.d.ts +66 -0
  107. package/dist/ui/entries/ServiceCard.js +116 -0
  108. package/dist/ui/entries/UserEntry.d.ts +17 -3
  109. package/dist/ui/entries/UserEntry.js +34 -5
  110. package/dist/ui/exit-snapshot.d.ts +9 -0
  111. package/dist/ui/layout/ScrollViewport.d.ts +1 -1
  112. package/dist/ui/layout/autocomplete.d.ts +18 -0
  113. package/dist/ui/layout/autocomplete.js +26 -0
  114. package/dist/ui/layout/budget.d.ts +34 -6
  115. package/dist/ui/layout/budget.js +32 -7
  116. package/dist/ui/layout/rail.d.ts +4 -53
  117. package/dist/ui/layout/rail.js +10 -62
  118. package/dist/ui/layout/team-panel.d.ts +18 -0
  119. package/dist/ui/layout/team-panel.js +22 -0
  120. package/dist/ui/layout/todo-layout.d.ts +24 -0
  121. package/dist/ui/layout/todo-layout.js +27 -0
  122. package/dist/ui/layout/virtual-window.js +51 -1
  123. package/dist/ui/overlays/HelpOverlay.js +3 -0
  124. package/dist/ui/overlays/PlanReviewOverlay.js +10 -2
  125. package/dist/ui/overlays/QuestionOverlay.js +5 -1
  126. package/dist/ui/overlays/SettingsScreen.js +14 -1
  127. package/dist/ui/paste-frames.d.ts +87 -0
  128. package/dist/ui/paste-frames.js +112 -0
  129. package/dist/ui/paste-tokens.d.ts +90 -0
  130. package/dist/ui/paste-tokens.js +147 -0
  131. package/dist/ui/screen.d.ts +16 -0
  132. package/dist/ui/screen.js +45 -2
  133. package/dist/ui/selection/selection-controller.d.ts +1 -1
  134. package/dist/ui/transcript-text.js +55 -2
  135. package/dist/ui/use-wheel-routing.d.ts +1 -1
  136. package/package.json +2 -2
  137. package/dist/input/stdin-mouse-filter.d.ts +0 -47
  138. package/dist/input/stdin-mouse-filter.js +0 -153
@@ -17,6 +17,7 @@
17
17
  import React from 'react';
18
18
  import type { Theme } from './theme.js';
19
19
  import type { TermCapabilities } from './capabilities.js';
20
+ import { pickGlyphs } from './glyphs.js';
20
21
  import { type CommandOption } from './PromptInput.js';
21
22
  import { type AgentMode } from '../agent/agent-mode.js';
22
23
  export interface ComposerProps {
@@ -48,6 +49,8 @@ export interface ComposerProps {
48
49
  */
49
50
  modeToggleKey?: string;
50
51
  popupMaxRows?: number;
52
+ popupMaxHeight?: number;
53
+ onPopupRowsChange?: (rows: number) => void;
51
54
  /**
52
55
  * Rows of newer output below the viewport, forwarded to the input row's chip
53
56
  * (tui-selection-and-scroll-follow §4.2 / G7).
@@ -60,9 +63,61 @@ export interface ComposerProps {
60
63
  * un-faded in `hintText`.
61
64
  */
62
65
  scrolledLines?: number;
66
+ /**
67
+ * Live background services (background-service-supervision §3.8), 0 when none.
68
+ *
69
+ * IT IS STATE, NOT A FADEABLE HINT, exactly like `scrolledLines` above: the
70
+ * clause it adds is the only visible way to stop something, and `hintText`
71
+ * below keeps it out of the faded form for the same reason `esc abort` is kept
72
+ * there.
73
+ */
74
+ services?: number;
75
+ /**
76
+ * The composer's DRAFT row count, forwarded upward (tui-paste-handling
77
+ * section 5.5).
78
+ *
79
+ * `hasDraft` stays here, because the border reacts to it and routing that
80
+ * through `App` would re-render the transcript on the first keystroke of every
81
+ * message. The row count is the opposite case: `layout/budget.ts` needs it, so
82
+ * it has to reach `App` — and it changes on a line break rather than on a
83
+ * character, which is what keeps the re-render rate the same (R-6).
84
+ */
85
+ onDraftRows?: (rows: number) => void;
86
+ /** Paste refusals from `PromptInput`, routed to `App`'s notice dispatch. */
87
+ onNotice?: (level: 'warn' | 'error', text: string) => void;
63
88
  theme: Theme;
64
89
  caps: TermCapabilities;
65
90
  onSubmit: (text: string) => void;
66
91
  onHelp?: () => void;
67
92
  }
68
- export declare function Composer({ isActive, running, history, commands, cwd, showHint, submitCount, hintsEnabled, agentMode, modeToggleKey, popupMaxRows, scrolledLines, theme, caps, onSubmit, onHelp, }: ComposerProps): React.ReactElement;
93
+ /**
94
+ * Build the hint row.
95
+ *
96
+ * The `running` branch is NEVER abbreviated, no matter how experienced the user
97
+ * is. Once the status bar's hint cluster was removed, the composer became the
98
+ * only place that names the abort key — and fading that out after eight
99
+ * submissions would leave a long-running task with no visible way to stop it.
100
+ * Progressive disclosure is for teaching discoverable things, not for retiring
101
+ * an emergency exit (§4.6 / A-14 / M-10b).
102
+ */
103
+ declare function hintText(opts: {
104
+ running: boolean;
105
+ submitCount: number;
106
+ glyphs: ReturnType<typeof pickGlyphs>;
107
+ mode: AgentMode;
108
+ toggleKey: string;
109
+ /** Live background services; 0 leaves both rows byte-identical to today's. */
110
+ services: number;
111
+ }): string;
112
+ /**
113
+ * `hintText`, for the interrupt-ladder tests (AC-43).
114
+ *
115
+ * EXPORTED UNDER A TEST-FACING NAME rather than exporting `hintText` itself, so
116
+ * the private helper stays private in the module graph and nothing in `src/`
117
+ * grows a second caller by accident. The row is the ONLY visible way to stop a
118
+ * process the agent left running, so it is worth asserting on directly rather
119
+ * than fishing the string out of a rendered frame.
120
+ */
121
+ export declare const hintTextForTest: typeof hintText;
122
+ export declare function Composer({ isActive, running, history, commands, cwd, showHint, submitCount, hintsEnabled, agentMode, services, modeToggleKey, popupMaxRows, popupMaxHeight, onPopupRowsChange, scrolledLines, onDraftRows, onNotice, theme, caps, onSubmit, onHelp, }: ComposerProps): React.ReactElement;
123
+ export {};
@@ -32,9 +32,28 @@ import { MODE_LABEL, MODE_TOGGLE_KEYS, nextMode } from '../agent/agent-mode.js';
32
32
  * Progressive disclosure is for teaching discoverable things, not for retiring
33
33
  * an emergency exit (§4.6 / A-14 / M-10b).
34
34
  */
35
- function hintText(running, submitCount, glyphs, mode, toggleKey) {
35
+ function hintText(opts) {
36
+ const { running, submitCount, glyphs, mode, toggleKey, services } = opts;
36
37
  const dot = ` ${glyphs.midDot} `;
38
+ // THE EXIT CLAUSE SURVIVES IN BOTH ROWS (P1-4). The obvious edit here is to
39
+ // replace `ctrl+c*2 exit` with `ctrl+c stop N` — and that would delete the
40
+ // only visible way to quit at exactly the moment Ctrl+C stops meaning "quit",
41
+ // on the screen a user reaches when something has already gone wrong. Both
42
+ // clauses are shown; the row is `wrap="truncate"` and they are ordered
43
+ // most-urgent-first, so a terminal too narrow for all of them drops the exit
44
+ // clause last rather than the abort key.
37
45
  if (running) {
46
+ if (services > 0) {
47
+ return [
48
+ `${glyphs.enterKey} steer`,
49
+ // Rung two is named because the whole point of the ladder is that the
50
+ // first press can fail; a user who does not know there is a second press
51
+ // is left exactly where the reported screenshot left them.
52
+ `esc abort${glyphs.times}2 force`,
53
+ `ctrl+c stop ${services}`,
54
+ `ctrl+c${glyphs.times}2 exit`,
55
+ ].join(dot);
56
+ }
38
57
  return [`${glyphs.enterKey} steer`, 'esc abort', `ctrl+c${glyphs.times}2 exit`].join(dot);
39
58
  }
40
59
  // The hint names the DESTINATION, not the current state — the correct label
@@ -45,9 +64,14 @@ function hintText(running, submitCount, glyphs, mode, toggleKey) {
45
64
  // retire things the user has demonstrably learned, and `submitCount` counts
46
65
  // submissions — a user can send fifty messages without ever discovering
47
66
  // Shift+Tab. Same argument that keeps `esc abort` un-faded above.
67
+ // NEVER FADED, for the reason the props doc gives: it is the only visible way
68
+ // to stop a process the agent left running, and progressive disclosure retires
69
+ // things the user has demonstrably learned — not emergency exits.
70
+ const stop = services > 0 ? [`ctrl+c stop ${services}`] : [];
48
71
  if (submitCount >= HINT_FADE_AFTER)
49
- return [toggle, '? help'].join(dot);
72
+ return [...stop, toggle, '? help'].join(dot);
50
73
  return [
74
+ ...stop,
51
75
  `${glyphs.enterKey} send`,
52
76
  `${glyphs.shiftEnter} newline`,
53
77
  '/ commands',
@@ -56,9 +80,22 @@ function hintText(running, submitCount, glyphs, mode, toggleKey) {
56
80
  '? help',
57
81
  ].join(dot);
58
82
  }
59
- export function Composer({ isActive, running, history, commands, cwd, showHint, submitCount, hintsEnabled, agentMode, modeToggleKey = MODE_TOGGLE_KEYS.primary, popupMaxRows, scrolledLines = 0, theme, caps, onSubmit, onHelp, }) {
83
+ /**
84
+ * `hintText`, for the interrupt-ladder tests (AC-43).
85
+ *
86
+ * EXPORTED UNDER A TEST-FACING NAME rather than exporting `hintText` itself, so
87
+ * the private helper stays private in the module graph and nothing in `src/`
88
+ * grows a second caller by accident. The row is the ONLY visible way to stop a
89
+ * process the agent left running, so it is worth asserting on directly rather
90
+ * than fishing the string out of a rendered frame.
91
+ */
92
+ export const hintTextForTest = hintText;
93
+ export function Composer({ isActive, running, history, commands, cwd, showHint, submitCount, hintsEnabled, agentMode, services = 0, modeToggleKey = MODE_TOGGLE_KEYS.primary, popupMaxRows, popupMaxHeight, onPopupRowsChange, scrolledLines = 0, onDraftRows, onNotice, theme, caps, onSubmit, onHelp, }) {
60
94
  const [hasDraft, setHasDraft] = useState(false);
61
- const onDraftChange = useCallback((next) => setHasDraft(next), []);
95
+ const onDraftChange = useCallback((next) => {
96
+ setHasDraft(next.hasDraft);
97
+ onDraftRows?.(next.rows);
98
+ }, [onDraftRows]);
62
99
  // Plan mode inserts ONE branch, above `draft` and below `running`. The
63
100
  // ordering is load-bearing: an in-flight run's border must stay `toolRunning`,
64
101
  // because "is it running" is more urgent than "which mode".
@@ -73,8 +110,15 @@ export function Composer({ isActive, running, history, commands, cwd, showHint,
73
110
  : theme.idleBorder ?? theme.border;
74
111
  const glyphs = pickGlyphs(caps);
75
112
  const visible = showHint && hintsEnabled;
76
- return (_jsxs(Box, { flexDirection: "column", flexShrink: 0, children: [_jsx(PromptInput, { isActive: isActive, running: running, history: history, commands: commands, cwd: cwd, theme: theme, caps: caps, popupMaxRows: popupMaxRows, onSubmit: onSubmit, onHelp: onHelp, agentMode: agentMode, bordered: { color: borderColor }, onDraftChange: onDraftChange, scrolledLines: scrolledLines }), visible && (
113
+ return (_jsxs(Box, { flexDirection: "column", flexShrink: 0, children: [_jsx(PromptInput, { isActive: isActive, running: running, history: history, commands: commands, cwd: cwd, theme: theme, caps: caps, popupMaxRows: popupMaxRows, popupMaxHeight: popupMaxHeight, onPopupRowsChange: onPopupRowsChange, onSubmit: onSubmit, onHelp: onHelp, agentMode: agentMode, bordered: { color: borderColor }, onDraftChange: onDraftChange, onNotice: onNotice, scrolledLines: scrolledLines }), visible && (
77
114
  // The chip rides the EXISTING hint row rather than adding one, so it
78
115
  // costs zero rows on a 24-row terminal.
79
- _jsxs(Box, { flexDirection: "row", flexShrink: 0, children: [_jsx(Text, { children: ' ' }), _jsx(ModeChip, { mode: agentMode, theme: theme, caps: caps }), _jsx(Text, { wrap: "truncate", color: theme.hintFg ?? theme.muted, children: hintText(running, submitCount, glyphs, agentMode, modeToggleKey) })] }))] }));
116
+ _jsxs(Box, { flexDirection: "row", flexShrink: 0, children: [_jsx(Text, { children: ' ' }), _jsx(ModeChip, { mode: agentMode, theme: theme, caps: caps }), _jsx(Text, { wrap: "truncate", color: theme.hintFg ?? theme.muted, children: hintText({
117
+ running,
118
+ submitCount,
119
+ glyphs,
120
+ mode: agentMode,
121
+ toggleKey: modeToggleKey,
122
+ services,
123
+ }) })] }))] }));
80
124
  }
@@ -24,6 +24,7 @@ import type { TermCapabilities } from './capabilities.js';
24
24
  import { pickGlyphs } from './glyphs.js';
25
25
  import { type EditIntent } from '../input/keymap.js';
26
26
  import { type Suggestion } from './AutocompletePopup.js';
27
+ import { type EditorState, type InputSegment } from './editor-reducer.js';
27
28
  import type { AgentMode } from '../agent/agent-mode.js';
28
29
  export interface CommandOption {
29
30
  name: string;
@@ -47,6 +48,9 @@ interface PromptInputProps {
47
48
  * `App` derives this from `budget.ts` so the popup shrinks instead.
48
49
  */
49
50
  popupMaxRows?: number;
51
+ /** Total menu rows, including borders and the overflow hint. */
52
+ popupMaxHeight?: number;
53
+ onPopupRowsChange?: (rows: number) => void;
50
54
  onSubmit: (text: string) => void;
51
55
  onHelp?: () => void;
52
56
  /**
@@ -64,8 +68,30 @@ interface PromptInputProps {
64
68
  bordered?: {
65
69
  color?: string;
66
70
  };
67
- /** Fires only when the draft flips empty ⇄ non-empty, for the border color. */
68
- onDraftChange?: (hasDraft: boolean) => void;
71
+ /**
72
+ * Fires when the draft flips empty ⇄ non-empty (for the border color) OR when
73
+ * its ROW COUNT changes (for `layout/budget.ts`) — never per keystroke.
74
+ *
75
+ * Widened from `(hasDraft: boolean)` by tui-paste-handling section 5.5. The row
76
+ * count is what makes I-8 true: the composer's rendered height and the
77
+ * `draftRows` handed to `viewportRows` have to be the same number, and the only
78
+ * component that knows the first one is this one. It is reported ALREADY
79
+ * CLAMPED, so a 218-line draft and a 400-line draft report the same value and
80
+ * the transcript stops moving once the composer is full (R-12).
81
+ */
82
+ onDraftChange?: (state: {
83
+ hasDraft: boolean;
84
+ rows: number;
85
+ }) => void;
86
+ /**
87
+ * Tell the user a paste was refused (D-11 / P1-4).
88
+ *
89
+ * `PASTE_DRAFT_MAX_BYTES` and `PASTE_MAX_BLOCKS` cannot be enforced in the
90
+ * filter (it does not know the draft) and cannot be enforced in the reducer (it
91
+ * is pure, so it can neither raise a toast nor say why it refused). This
92
+ * component is the only place that holds both facts.
93
+ */
94
+ onNotice?: (level: 'warn' | 'error', text: string) => void;
69
95
  /**
70
96
  * Rows of newer output hidden BELOW the viewport — `ScrollViewport`'s offset
71
97
  * (tui-selection-and-scroll-follow §4.2 / G7).
@@ -99,6 +125,29 @@ export interface ScrollChip {
99
125
  * scan fails the build if that rule is broken here.
100
126
  */
101
127
  export declare function scrollChip(scrolledLines: number, cols: number, glyphs: ReturnType<typeof pickGlyphs>): ScrollChip | null;
128
+ export interface OverflowChip {
129
+ text: string;
130
+ /** FIXED width, so a number gaining a digit cannot re-wrap the draft. */
131
+ cells: number;
132
+ }
133
+ /**
134
+ * Build the "there is more draft above / below this window" indicator, or `null`
135
+ * when the whole draft is on screen.
136
+ *
137
+ * Every glyph comes from `pickGlyphs` and none from a literal (I-5): on a legacy
138
+ * console this renders `^3 v7` rather than mojibake, and `glyphs.test.ts`'s
139
+ * static scan fails the build if that rule is broken here.
140
+ */
141
+ export declare function overflowChip(hiddenAbove: number, hiddenBelow: number, glyphs: ReturnType<typeof pickGlyphs>): OverflowChip | null;
142
+ /**
143
+ * Would these segments push the draft past a limit? Returns the refusal text, or
144
+ * `null` when the paste is allowed.
145
+ *
146
+ * Checked BEFORE `dispatch`, so a refusal dispatches nothing at all and the draft
147
+ * is left byte-for-byte unchanged (AC-9). The one-dispatch-per-key rule is
148
+ * unaffected for the same reason.
149
+ */
150
+ export declare function draftLimitRefusal(segments: InputSegment[], editor: Pick<EditorState, 'pastes'>): string | null;
102
151
  /** Apply an edit intent as a pure buffer/cursor transform. */
103
152
  export declare function applyEdit(buffer: string, cursor: number, intent: EditIntent): {
104
153
  buffer: string;
@@ -127,5 +176,5 @@ export declare function fileTokenAt(buffer: string, cursor: number): {
127
176
  end: number;
128
177
  query: string;
129
178
  } | null;
130
- export declare function PromptInput({ isActive, running, history, commands, cwd, theme, caps, popupMaxRows, agentMode, onSubmit, onHelp, bordered, onDraftChange, scrolledLines, }: PromptInputProps): React.ReactElement;
179
+ export declare function PromptInput({ isActive, running, history, commands, cwd, theme, caps, popupMaxRows, popupMaxHeight, onPopupRowsChange, agentMode, onSubmit, onHelp, bordered, onDraftChange, onNotice, scrolledLines, }: PromptInputProps): React.ReactElement;
131
180
  export {};
@@ -1,4 +1,4 @@
1
- import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
1
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  /**
3
3
  * PromptInput (spec §3.6 / §3.10) — a controlled multiline input on Ink's
4
4
  * `useInput` with: full line editing (Home/End, word jump/delete, kill-line via
@@ -19,13 +19,19 @@ import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
19
19
  * stays a plain `useState` because it is written from the debounced glob effect
20
20
  * rather than from a key.
21
21
  */
22
- import { useEffect, useReducer, useRef, useState } from 'react';
22
+ import { useEffect, useLayoutEffect, useReducer, useRef, useState } from 'react';
23
23
  import { Box, Text, useInput, useStdout } from 'ink';
24
24
  import { glob as tinyGlob } from 'tinyglobby';
25
25
  import { pickGlyphs } from './glyphs.js';
26
26
  import { recognize } from '../input/keymap.js';
27
27
  import { AutocompletePopup } from './AutocompletePopup.js';
28
- import { editorReducer, INITIAL_EDITOR_STATE } from './editor-reducer.js';
28
+ import { buildAutocompleteLayout } from './layout/autocomplete.js';
29
+ import { editorReducer, INITIAL_EDITOR_STATE, } from './editor-reducer.js';
30
+ import { hasPasteFrame, splitPasteFrames } from './paste-frames.js';
31
+ import { allocatePasteId, expandPastes, expandRangeOverTokens } from './paste-tokens.js';
32
+ import { draftMaxRows } from './composer-limits.js';
33
+ import { layoutComposer, splitRowAtColumn } from './composer-rows.js';
34
+ import { PASTE_DRAFT_MAX_BYTES, PASTE_MAX_BLOCKS, formatPasteSize, } from '../input/limits.js';
29
35
  // ---------------------------------------------------------------------------
30
36
  // The scroll chip (§4.2)
31
37
  // ---------------------------------------------------------------------------
@@ -63,6 +69,63 @@ export function scrollChip(scrolledLines, cols, glyphs) {
63
69
  };
64
70
  }
65
71
  // ---------------------------------------------------------------------------
72
+ // The draft overflow indicator (section 5.5)
73
+ // ---------------------------------------------------------------------------
74
+ /** `^3 v7` at its widest, so the cell is a CONSTANT (the P2-4 discipline). */
75
+ const OVERFLOW_CELLS = 12;
76
+ const OVERFLOW_MAX_N = 999;
77
+ /**
78
+ * Build the "there is more draft above / below this window" indicator, or `null`
79
+ * when the whole draft is on screen.
80
+ *
81
+ * Every glyph comes from `pickGlyphs` and none from a literal (I-5): on a legacy
82
+ * console this renders `^3 v7` rather than mojibake, and `glyphs.test.ts`'s
83
+ * static scan fails the build if that rule is broken here.
84
+ */
85
+ export function overflowChip(hiddenAbove, hiddenBelow, glyphs) {
86
+ if (hiddenAbove <= 0 && hiddenBelow <= 0)
87
+ return null;
88
+ const show = (n) => (n > OVERFLOW_MAX_N ? `${OVERFLOW_MAX_N}+` : String(n));
89
+ const parts = [];
90
+ if (hiddenAbove > 0)
91
+ parts.push(`${glyphs.arrowUp}${show(hiddenAbove)}`);
92
+ if (hiddenBelow > 0)
93
+ parts.push(`${glyphs.arrowDown}${show(hiddenBelow)}`);
94
+ return { text: parts.join(' '), cells: OVERFLOW_CELLS };
95
+ }
96
+ // ---------------------------------------------------------------------------
97
+ // Draft-level paste limits (section 5.3, the P1-4 enforcement point)
98
+ // ---------------------------------------------------------------------------
99
+ /**
100
+ * Would these segments push the draft past a limit? Returns the refusal text, or
101
+ * `null` when the paste is allowed.
102
+ *
103
+ * Checked BEFORE `dispatch`, so a refusal dispatches nothing at all and the draft
104
+ * is left byte-for-byte unchanged (AC-9). The one-dispatch-per-key rule is
105
+ * unaffected for the same reason.
106
+ */
107
+ export function draftLimitRefusal(segments, editor) {
108
+ let liveBytes = 0;
109
+ for (const record of editor.pastes.values())
110
+ liveBytes += Buffer.byteLength(record.text, 'utf8');
111
+ let blocks = editor.pastes.size;
112
+ for (const segment of segments) {
113
+ if (segment.kind !== 'paste')
114
+ continue;
115
+ liveBytes += Buffer.byteLength(segment.text, 'utf8');
116
+ blocks += 1;
117
+ }
118
+ if (blocks > PASTE_MAX_BLOCKS) {
119
+ return (`Too many pasted blocks in one message (limit is ${PASTE_MAX_BLOCKS}). ` +
120
+ 'Nothing was inserted.');
121
+ }
122
+ if (liveBytes > PASTE_DRAFT_MAX_BYTES) {
123
+ return (`Pasted content in this message would reach ${formatPasteSize(liveBytes)}; ` +
124
+ `limit is ${formatPasteSize(PASTE_DRAFT_MAX_BYTES)}. Nothing was inserted.`);
125
+ }
126
+ return null;
127
+ }
128
+ // ---------------------------------------------------------------------------
66
129
  // Pure editing helpers (unit-tested in input.test.ts)
67
130
  // ---------------------------------------------------------------------------
68
131
  const isSpace = (ch) => !!ch && /\s/.test(ch);
@@ -90,9 +153,25 @@ function nextWord(buffer, cursor) {
90
153
  i += 1;
91
154
  return i;
92
155
  }
156
+ /**
157
+ * Delete `[from, to)`, first growing the range over any token it clips (G5 /
158
+ * D-8).
159
+ *
160
+ * A no-op growth on a buffer with no tokens, so every pre-existing assertion in
161
+ * `input.test.ts` holds byte-for-byte. With a token present it is what stops
162
+ * `Ctrl+W` / `Ctrl+U` / `Ctrl+K` leaving half a label behind — which would be a
163
+ * label the user then sends INSTEAD of the text it stands for.
164
+ */
165
+ function killRange(buffer, from, to) {
166
+ const range = expandRangeOverTokens(buffer, from, to);
167
+ return { buffer: buffer.slice(0, range.from) + buffer.slice(range.to), cursor: range.from };
168
+ }
93
169
  /** Apply an edit intent as a pure buffer/cursor transform. */
94
170
  export function applyEdit(buffer, cursor, intent) {
95
171
  switch (intent) {
172
+ // Cursor MOVEMENT is deliberately not token-aware (D-8): arrows, Home/End
173
+ // and word jumps step by character, because re-deriving `moveVertical` around
174
+ // a synthetic unit buys a correctness the user cannot notice.
96
175
  case 'home':
97
176
  return { buffer, cursor: lineStart(buffer, cursor) };
98
177
  case 'end':
@@ -101,18 +180,12 @@ export function applyEdit(buffer, cursor, intent) {
101
180
  return { buffer, cursor: prevWord(buffer, cursor) };
102
181
  case 'wordRight':
103
182
  return { buffer, cursor: nextWord(buffer, cursor) };
104
- case 'deleteWordBack': {
105
- const start = prevWord(buffer, cursor);
106
- return { buffer: buffer.slice(0, start) + buffer.slice(cursor), cursor: start };
107
- }
108
- case 'killToStart': {
109
- const start = lineStart(buffer, cursor);
110
- return { buffer: buffer.slice(0, start) + buffer.slice(cursor), cursor: start };
111
- }
112
- case 'killToEnd': {
113
- const end = lineEnd(buffer, cursor);
114
- return { buffer: buffer.slice(0, cursor) + buffer.slice(end), cursor };
115
- }
183
+ case 'deleteWordBack':
184
+ return killRange(buffer, prevWord(buffer, cursor), cursor);
185
+ case 'killToStart':
186
+ return killRange(buffer, lineStart(buffer, cursor), cursor);
187
+ case 'killToEnd':
188
+ return killRange(buffer, cursor, lineEnd(buffer, cursor));
116
189
  }
117
190
  }
118
191
  /**
@@ -174,7 +247,7 @@ function isControlSeq(input) {
174
247
  // ---------------------------------------------------------------------------
175
248
  // Component
176
249
  // ---------------------------------------------------------------------------
177
- export function PromptInput({ isActive, running, history, commands, cwd, theme, caps, popupMaxRows, agentMode, onSubmit, onHelp, bordered, onDraftChange, scrolledLines = 0, }) {
250
+ export function PromptInput({ isActive, running, history, commands, cwd, theme, caps, popupMaxRows, popupMaxHeight, onPopupRowsChange, agentMode, onSubmit, onHelp, bordered, onDraftChange, onNotice, scrolledLines = 0, }) {
178
251
  const { stdout } = useStdout();
179
252
  const [editor, dispatch] = useReducer(editorReducer, INITIAL_EDITOR_STATE);
180
253
  const { buffer, cursor, historyIndex, dismissed, sel } = editor;
@@ -191,6 +264,19 @@ export function PromptInput({ isActive, running, history, commands, cwd, theme,
191
264
  ? fileMatches.map((p) => ({ label: p }))
192
265
  : [];
193
266
  const clampedSel = popupItems.length > 0 ? Math.min(sel, popupItems.length - 1) : 0;
267
+ const popupLayout = buildAutocompleteLayout({
268
+ itemCount: isActive ? popupItems.length : 0,
269
+ selected: clampedSel,
270
+ maxRows: popupMaxRows,
271
+ maxHeight: popupMaxHeight,
272
+ });
273
+ const popupVisible = isActive && popupLayout.rowCount > 0;
274
+ // Report on every mount, including zero. Cleanup belongs to a separate
275
+ // effect so changing height does not publish an intermediate zero budget.
276
+ useLayoutEffect(() => {
277
+ onPopupRowsChange?.(popupLayout.rowCount);
278
+ }, [onPopupRowsChange, popupLayout.rowCount]);
279
+ useLayoutEffect(() => () => onPopupRowsChange?.(0), [onPopupRowsChange]);
194
280
  // --- Debounced `@file` scan. ---------------------------------------------
195
281
  useEffect(() => {
196
282
  if (fileQuery === null || fileQuery.length < 1) {
@@ -221,18 +307,62 @@ export function PromptInput({ isActive, running, history, commands, cwd, theme,
221
307
  clearTimeout(id);
222
308
  };
223
309
  }, [fileQuery, cwd]);
224
- // --- Draft presence, reported only on the transition. ---------------------
225
- // Notifying on every keystroke would re-render the whole App tree per key.
310
+ // --- Layout (section 5.5). -----------------------------------------------
311
+ //
312
+ // COMPUTED HERE, ABOVE THE REPORTING EFFECT, because the number the effect
313
+ // sends upward has to be the number this component actually renders (I-8).
314
+ // Deriving it twice — once for the frame and once for the budget — is exactly
315
+ // the trap `BottomStatusRow.tsx:5-27` documents.
316
+ const glyphs = pickGlyphs(caps);
317
+ const cols = stdout?.columns ?? 80;
318
+ const terminalRows = stdout?.rows ?? 24;
319
+ // Full-screen only: inline mode has no self-drawn viewport, so `scrolledLines`
320
+ // is always 0 there and the branch is unreachable rather than merely unused.
321
+ // The guard is explicit so it stays that way.
322
+ const chip = bordered ? scrollChip(scrolledLines, cols, glyphs) : null;
323
+ // Err NARROW. Wrapping wider than the box Ink gives us would make Ink wrap a
324
+ // second time, and then `layout.rows.length` is a lie — which is the one thing
325
+ // this module may not be.
326
+ const chromeCols = bordered ? 2 /* border */ + 2 /* paddingX */ + 2 /* marker */ : 2;
327
+ const baseCols = Math.max(8, cols - chromeCols - (chip ? chip.cells + 1 : 0));
328
+ const measure = (wrapCols) => layoutComposer({
329
+ buffer,
330
+ cursor,
331
+ cols: wrapCols,
332
+ maxRows: draftMaxRows(terminalRows),
333
+ active: isActive,
334
+ });
335
+ // TWO PASSES, AND THE SECOND ONE IS LOAD-BEARING (I-8). The overflow indicator
336
+ // rides the last rendered row INSIDE the box the draft wraps in, so its cells
337
+ // have to come out of the wrap width. Measured at the full width instead, a
338
+ // last row that fills the line is wrapped a SECOND time by Ink, and the
339
+ // composer then draws one row more than `layout.rows.length` — which is the
340
+ // number this component hands to `viewportRows`. That is defect C again, in
341
+ // precisely the situation this feature exists for: a draft too tall to show.
342
+ //
343
+ // It settles in two. Narrowing can only ADD rows, so a draft that overflowed
344
+ // the first pass still overflows the second, and a third pass would measure
345
+ // the same width as the second.
346
+ const wide = measure(baseCols);
347
+ const overflows = wide.hiddenAbove > 0 || wide.hiddenBelow > 0;
348
+ const usableCols = overflows ? Math.max(8, baseCols - OVERFLOW_CELLS - 1) : baseCols;
349
+ const layout = overflows ? measure(usableCols) : wide;
350
+ const draftRowCount = Math.max(1, layout.rows.length);
351
+ // --- Draft presence and height, reported only on a transition. -----------
352
+ // Notifying on every keystroke would re-render the whole App tree per key. The
353
+ // row count changes on a line break or a wrap boundary, not per character
354
+ // (R-6), so widening this callback does not widen how often it fires.
226
355
  const hasDraft = buffer.length > 0;
227
356
  const lastReportedDraft = useRef(null);
228
357
  useEffect(() => {
229
358
  if (!onDraftChange)
230
359
  return;
231
- if (lastReportedDraft.current === hasDraft)
360
+ const last = lastReportedDraft.current;
361
+ if (last && last.hasDraft === hasDraft && last.rows === draftRowCount)
232
362
  return;
233
- lastReportedDraft.current = hasDraft;
234
- onDraftChange(hasDraft);
235
- }, [hasDraft, onDraftChange]);
363
+ lastReportedDraft.current = { hasDraft, rows: draftRowCount };
364
+ onDraftChange({ hasDraft, rows: draftRowCount });
365
+ }, [hasDraft, draftRowCount, onDraftChange]);
236
366
  // --- Mutators. -----------------------------------------------------------
237
367
  // ONE DISPATCH EACH, and that is the rule §3.4 asks a reviewer to check line
238
368
  // by line. Two dispatches from one key would put the legacy root back to two
@@ -240,8 +370,16 @@ export function PromptInput({ isActive, running, history, commands, cwd, theme,
240
370
  const insert = (text) => {
241
371
  dispatch({ type: 'insert', text });
242
372
  };
373
+ /**
374
+ * Expand every token back to its payload, then send (D-7 / D-9).
375
+ *
376
+ * `onSubmit`'s signature is unchanged, so `Composer`, `App.handleSubmit`,
377
+ * `runSlashInput` and `submitMessage` are all untouched — and a slash command
378
+ * therefore sees the EXPANDED text, which is what `/skill foo <pasted body>`
379
+ * needs. `clear` releases every payload in the same dispatch.
380
+ */
243
381
  const submit = (text) => {
244
- onSubmit(text);
382
+ onSubmit(expandPastes(text, editor.pastes));
245
383
  dispatch({ type: 'clear' });
246
384
  };
247
385
  const recallUp = () => {
@@ -304,7 +442,7 @@ export function PromptInput({ isActive, running, history, commands, cwd, theme,
304
442
  if (key.tab && key.shift)
305
443
  return;
306
444
  // Popup navigation owns Up/Down/Tab/→/Enter/Esc while it is open.
307
- if (popupItems.length > 0) {
445
+ if (popupVisible) {
308
446
  if (key.upArrow) {
309
447
  dispatch({ type: 'select', sel: Math.max(0, clampedSel - 1) });
310
448
  return;
@@ -382,11 +520,32 @@ export function PromptInput({ isActive, running, history, commands, cwd, theme,
382
520
  onHelp();
383
521
  return;
384
522
  }
523
+ // A PASTE (section 5.4). It sits AFTER every key branch so a paste can
524
+ // never be shadowed by a key test, and BEFORE `isControlSeq` because a
525
+ // framed string starts with NUL and would otherwise be dropped.
526
+ //
527
+ // The text runs inside `segments` are already sanitised (I-14): Ink drains
528
+ // its whole buffer in one `read()`, so this same string can carry the `\r`
529
+ // of a user who pasted and pressed Enter inside the burst window.
530
+ if (hasPasteFrame(input)) {
531
+ const runs = splitPasteFrames(input);
532
+ const segments = runs.map((run) => run.kind === 'paste' ? { kind: 'paste', text: run.text, id: allocatePasteId() } : run);
533
+ if (segments.length === 0)
534
+ return;
535
+ const refusal = draftLimitRefusal(segments, editor);
536
+ if (refusal) {
537
+ // A refusal dispatches NOTHING, so the draft is byte-for-byte
538
+ // unchanged (AC-9) — and it is never silent (D-11).
539
+ onNotice?.('warn', refusal);
540
+ return;
541
+ }
542
+ dispatch({ type: 'input', segments });
543
+ return;
544
+ }
385
545
  // Printable input — drop any unrecognized control/escape byte.
386
546
  if (input && !key.tab && !isControlSeq(input))
387
547
  insert(input);
388
548
  }, { isActive });
389
- const glyphs = pickGlyphs(caps);
390
549
  const marker = running ? glyphs.steer : glyphs.caret;
391
550
  const markerColor = running ? theme.toolRunning : theme.primary;
392
551
  const placeholder = running
@@ -394,12 +553,22 @@ export function PromptInput({ isActive, running, history, commands, cwd, theme,
394
553
  : agentMode === 'plan'
395
554
  ? `Describe what you want to build; I'll research and plan it first${glyphs.ellipsis}`
396
555
  : `Send a message (/ for commands, @ for files)${glyphs.ellipsis}`;
397
- const lines = renderWithCursor(buffer, cursor, isActive);
398
- // Full-screen only: inline mode has no self-drawn viewport, so `scrolledLines`
399
- // is always 0 there and the branch is unreachable rather than merely unused.
400
- // The guard is explicit so it stays that way.
401
- const chip = bordered ? scrollChip(scrolledLines, stdout?.columns ?? 80, glyphs) : null;
402
- const inputRow = (_jsxs(Box, { flexDirection: "row", children: [_jsxs(Text, { color: markerColor, bold: true, children: [marker, ' '] }), _jsx(Box, { flexDirection: "column", flexGrow: 1, flexShrink: 1, children: buffer.length === 0 ? (_jsx(Text, { color: theme.muted, children: placeholder })) : (lines.map((line, i) => _jsx(Text, { children: line }, i))) }), chip && (
556
+ const overflow = overflowChip(layout.hiddenAbove, layout.hiddenBelow, glyphs);
557
+ const tokenColor = theme.hintFg ?? theme.muted;
558
+ const renderSegments = (segments, keyPrefix) => segments.map((segment, i) => (_jsx(Text, { color: segment.kind === 'token' ? tokenColor : undefined, children: segment.text }, `${keyPrefix}${i}`)));
559
+ const rowNodes = layout.rows.map((row, i) => {
560
+ if (i !== layout.cursorRow)
561
+ return _jsx(_Fragment, { children: renderSegments(row.segments, `s${i}.`) });
562
+ // The caret is drawn as one inverse cell, split at the column
563
+ // `layoutComposer` reported — the same width table it wrapped with (I-9).
564
+ const split = splitRowAtColumn(row, layout.cursorCol);
565
+ return (_jsxs(_Fragment, { children: [renderSegments(split.before, `b${i}.`), _jsx(Text, { inverse: true, children: split.at ? split.at.text : ' ' }), renderSegments(split.after, `a${i}.`)] }));
566
+ });
567
+ const inputRow = (_jsxs(Box, { flexDirection: "row", children: [_jsxs(Text, { color: markerColor, bold: true, children: [marker, ' '] }), _jsx(Box, { flexDirection: "column", flexGrow: 1, flexShrink: 1, children: buffer.length === 0 ? (_jsx(Text, { wrap: "truncate", color: theme.muted, children: placeholder })) : (rowNodes.map((node, i) => overflow && i === rowNodes.length - 1 ? (
568
+ // The overflow indicator rides the LAST rendered row rather than
569
+ // taking one of its own: an extra row would put the composer's
570
+ // real height one above the `draftRows` it just reported (I-8).
571
+ _jsxs(Box, { flexDirection: "row", children: [_jsx(Box, { flexGrow: 1, flexShrink: 1, children: _jsx(Text, { children: node }) }), _jsx(Box, { flexShrink: 0, width: overflow.cells, justifyContent: "flex-end", children: _jsx(Text, { wrap: "truncate", color: theme.hintFg ?? theme.muted, children: overflow.text }) })] }, i)) : (_jsx(Text, { children: node }, i)))) }), chip && (
403
572
  // `alignSelf="flex-start"` pins the chip to the FIRST row of a multi-line
404
573
  // draft. Yoga's default `stretch` sizes this box to the full height of the
405
574
  // editor; it renders on the top row either way today, and relying on that
@@ -410,28 +579,5 @@ export function PromptInput({ isActive, running, history, commands, cwd, theme,
410
579
  // the user is typing — `↓ 9` becomes `↓ 10` becomes `↓ 100` — and every
411
580
  // change would otherwise re-wrap a draft that fills the line.
412
581
  _jsx(Box, { flexShrink: 0, alignSelf: "flex-start", marginLeft: 1, width: chip.cells, justifyContent: "flex-end", children: _jsx(Text, { wrap: "truncate", color: theme.hintFg ?? theme.muted, children: chip.text }) }))] }));
413
- return (_jsxs(Box, { flexDirection: "column", flexShrink: 0, children: [popupItems.length > 0 && (_jsx(AutocompletePopup, { items: popupItems, selected: clampedSel, theme: theme, caps: caps, maxRows: popupMaxRows })), bordered ? (_jsx(Box, { flexDirection: "column", flexShrink: 0, borderStyle: glyphs.boxStyle, borderColor: bordered.color, paddingX: 1, children: inputRow })) : (inputRow)] }));
414
- }
415
- /** Split the buffer into lines and render an inverse cursor at `cursor`. */
416
- function renderWithCursor(buffer, cursor, active) {
417
- if (!active)
418
- return buffer.split('\n');
419
- const before = buffer.slice(0, cursor);
420
- const at = buffer[cursor] ?? ' ';
421
- const after = buffer.slice(cursor + 1);
422
- const beforeLines = before.split('\n');
423
- const afterLines = after.split('\n');
424
- const nodes = [];
425
- const lastBeforeIdx = beforeLines.length - 1;
426
- for (let i = 0; i < lastBeforeIdx; i += 1) {
427
- nodes.push(_jsx(Text, { children: beforeLines[i] }, `b${i}`));
428
- }
429
- const cursorChar = at === '\n' ? ' ' : at;
430
- const firstAfter = afterLines[0] ?? '';
431
- nodes.push(_jsxs(Text, { children: [beforeLines[lastBeforeIdx], _jsx(Text, { inverse: true, children: cursorChar }), at === '\n' ? '' : firstAfter] }, "cursor-line"));
432
- const remainingAfter = at === '\n' ? afterLines : afterLines.slice(1);
433
- for (let i = 0; i < remainingAfter.length; i += 1) {
434
- nodes.push(_jsx(Text, { children: remainingAfter[i] }, `a${i}`));
435
- }
436
- return nodes;
582
+ return (_jsxs(Box, { flexDirection: "column", flexShrink: 0, children: [popupVisible && (_jsx(AutocompletePopup, { items: popupItems, selected: clampedSel, theme: theme, caps: caps, maxRows: popupMaxRows, layout: popupLayout })), bordered ? (_jsx(Box, { flexDirection: "column", flexShrink: 0, borderStyle: glyphs.boxStyle, borderColor: bordered.color, paddingX: 1, children: inputRow })) : (inputRow)] }));
437
583
  }