@aragon-agent/cli 0.6.45 → 0.6.46

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.
@@ -17,7 +17,7 @@
17
17
  * file therefore holds ONE delegation and one line of deprecation text, and
18
18
  * `subagent-command.test.ts` asserts both (I-8).
19
19
  */
20
- import { runTeamCommand } from './team-command.js';
20
+ import { runTeamCommand, teamRendersInvocationCard } from './team-command.js';
21
21
  /**
22
22
  * Run `/team`, having first said out loud that this is the same thing.
23
23
  *
@@ -33,6 +33,8 @@ export function runSubagentCommand(ctx) {
33
33
  export function registerSubagentCommand(registry) {
34
34
  registry.register({
35
35
  name: 'subagent',
36
+ // A fresh object inherits nothing from `/team`: reference the SAME predicate (RV-5).
37
+ rendersInvocationCard: teamRendersInvocationCard,
36
38
  description: 'Deprecated alias of /team - same arguments',
37
39
  run: (ctx) => { runSubagentCommand(ctx); },
38
40
  });
@@ -43,6 +43,12 @@
43
43
  * `Team 4` chip would wait for an unrelated state change before appearing.
44
44
  */
45
45
  import type { CommandContext, CommandRegistry } from './registry.js';
46
+ /**
47
+ * Whether `/team <args>` ends in a card. The SAME function the command branches on
48
+ * (`readTeamIntent`), and shared by `/team` and the `/subagent` alias by reference: the
49
+ * alias builds a fresh object and inherits nothing (RV-5, I-8).
50
+ */
51
+ export declare function teamRendersInvocationCard(args: string): boolean;
46
52
  /** The command body; `registerTeamCommand` is the only registration site. */
47
53
  export declare function runTeamCommand(ctx: CommandContext): void;
48
54
  /** Registered from `builtins.ts` with one line, next to every other subsystem. */
@@ -43,7 +43,7 @@
43
43
  * `Team 4` chip would wait for an unrelated state change before appearing.
44
44
  */
45
45
  import { buildForcedDirective } from '../team/forced-dispatch.js';
46
- import { DEFAULT_FORCED_COUNT, planForced, planNotice, readTeamIntent, } from '../team/forced-mode.js';
46
+ import { DEFAULT_FORCED_COUNT, forcedCardNote, planForced, planNotice, readTeamIntent, } from '../team/forced-mode.js';
47
47
  import { subagentModelSummary } from '../ui/overlays/model-profile-state.js';
48
48
  /**
49
49
  * The subagent model line, phrased by the same authority the settings root row
@@ -199,6 +199,34 @@ function enterForcedMode(ctx) {
199
199
  ctx.notify('info', `Forced team mode on: ${adopted} subagents per turn. `
200
200
  + 'Your next message dispatches.');
201
201
  }
202
+ /**
203
+ * The card's raw material for `/team <brief>` (tui-invocation-cards section 3.3). The
204
+ * command line is always spelled `/team`, even when the user came in through the
205
+ * deprecated `/subagent` alias: the card names the canonical command.
206
+ */
207
+ function forcedCardInput(args, planned, note, ctx) {
208
+ const noun = planned.count === 1 ? 'subagent' : 'subagents';
209
+ const facts = [
210
+ planned.parts.length > 0 ? `parts ${planned.parts.join(', ')}` : `${planned.count} ${noun}`,
211
+ ...(planned.readOnly ? ['read-only'] : []),
212
+ ...(ctx.controller.getAgentMode() === 'plan' ? ['plan mode'] : []),
213
+ ];
214
+ return {
215
+ variant: 'team',
216
+ commandLine: args === '' ? '/team' : `/team ${args}`,
217
+ input: planned.brief,
218
+ facts,
219
+ ...(note !== undefined ? { note } : {}),
220
+ };
221
+ }
222
+ /**
223
+ * Whether `/team <args>` ends in a card. The SAME function the command branches on
224
+ * (`readTeamIntent`), and shared by `/team` and the `/subagent` alias by reference: the
225
+ * alias builds a fresh object and inherits nothing (RV-5, I-8).
226
+ */
227
+ export function teamRendersInvocationCard(args) {
228
+ return readTeamIntent(args).kind === 'brief';
229
+ }
202
230
  /** `/team [-n n|--parts a,b] [--read-only] <brief>`: arm and submit in one act. */
203
231
  function armAndSubmit(ctx, intent) {
204
232
  const team = ctx.controller.getTeamConfig();
@@ -209,7 +237,11 @@ function armAndSubmit(ctx, intent) {
209
237
  // directive names: the later turns reuse this count, so the first turn and the
210
238
  // rest of the session cannot disagree about how wide the fan-out is (D-7).
211
239
  ctx.controller.armForcedTeam(planned.count);
212
- ctx.notify('info', planNotice(planned, team));
240
+ // With cards on, the disclosure rides on the card (`forcedCardNote`, same source as
241
+ // `planNotice`); with them off this is the pre-card notice, byte for byte.
242
+ const cards = ctx.invocationCards !== false;
243
+ if (!cards)
244
+ ctx.notify('info', planNotice(planned, team));
213
245
  const directive = buildForcedDirective({
214
246
  brief: planned.brief,
215
247
  count: planned.count,
@@ -222,7 +254,11 @@ function armAndSubmit(ctx, intent) {
222
254
  });
223
255
  // `userInitiated: false`: a directive the user did not type belongs in neither
224
256
  // the prompt history nor the hint-fade counter (todo-plan-followthrough C-12).
225
- ctx.submit(directive, { userInitiated: false });
257
+ const note = forcedCardNote(planned, team);
258
+ ctx.submit(directive, {
259
+ userInitiated: false,
260
+ ...(cards ? { invocation: forcedCardInput(ctx.args, planned, note, ctx) } : {}),
261
+ });
226
262
  }
227
263
  /** The command body; `registerTeamCommand` is the only registration site. */
228
264
  export function runTeamCommand(ctx) {
@@ -253,6 +289,7 @@ export function runTeamCommand(ctx) {
253
289
  export function registerTeamCommand(registry) {
254
290
  registry.register({
255
291
  name: 'team',
292
+ rendersInvocationCard: teamRendersInvocationCard,
256
293
  description: 'Force team mode and start now: /team <brief>; /team [on|off|status]',
257
294
  run: (ctx) => { runTeamCommand(ctx); },
258
295
  });
@@ -9,6 +9,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
9
9
  import { dirname, isAbsolute, join, resolve } from 'node:path';
10
10
  import { getSessionsDir } from '../config/store.js';
11
11
  import { stripEphemeralRuns } from '../team/settled.js';
12
+ import { sanitizeInvocationCard } from '../agent/invocation-card.js';
12
13
  import { validateSession } from './validate-session.js';
13
14
  import { validateCompactionIdentity, verifyCompactionIdentity, } from '../compaction/memory-identity.js';
14
15
  import { getLogger } from '../logging/logger.js';
@@ -227,6 +228,15 @@ export function normalizeLoadedEntries(entries) {
227
228
  // THE ID IS KEPT: `restoreEntries` splices these positions into the
228
229
  // live transcript, and a fresh id here would collide with the next
229
230
  // `e<n>` the reducer allocates.
231
+ if (entry.kind === 'user' && 'invocation' in entry) {
232
+ // THE EIGHTH CLAUSE (tui-invocation-cards I-7). A session file is user-editable
233
+ // and `shape()` ignores undeclared fields, so a card arrives unchecked. Repair
234
+ // what can be repaired; a card that cannot be is dropped and the entry degrades
235
+ // to a plain user message - a corrupt card must never cost the session.
236
+ const { invocation: stored, ...plain } = entry;
237
+ const card = sanitizeInvocationCard(stored);
238
+ return card ? { ...plain, invocation: card } : plain;
239
+ }
230
240
  if (entry.kind === 'queued') {
231
241
  const downgraded = {
232
242
  id: entry.id,
@@ -145,14 +145,16 @@ const subagent = shape({
145
145
  retry: optional(shape({ attempt: number, maxRetries: number })),
146
146
  });
147
147
  const ENTRY_FIELDS = {
148
- user: { text: string },
148
+ // `invocation` is declared for self-description only: `shape()` ignores undeclared
149
+ // fields, and `normalizeLoadedEntries` repairs or drops a bad card (tui-invocation-cards).
150
+ user: { text: string, invocation: optional(() => { }) },
149
151
  // A GOVERNANCE CARD IS HISTORY, so it is validated LENIENTLY (governance-mode 10.4):
150
152
  // unknown fields are ignored (`shape` only reads the declared ones) and `card` is not
151
153
  // shape-checked here at all - `sanitizeGovernanceEntries` below repairs a card it can
152
154
  // and degrades one it cannot to a one-line notice, so a corrupt footnote never costs
153
155
  // the session. `at` is advisory.
154
156
  governance: { at: optional(number), card: () => { } },
155
- queued: { text: string, queueId: optional(nonempty) },
157
+ queued: { text: string, queueId: optional(nonempty), invocation: optional(() => { }) },
156
158
  notice: { text: string, level: enumeration('info', 'warn', 'error') },
157
159
  assistant: {
158
160
  text: string, thinking: optional(string), thinkingOpen: boolean, streaming: boolean,
@@ -106,6 +106,19 @@ export interface ForcedPlan {
106
106
  * can apply, because only it can read the live config.
107
107
  */
108
108
  export declare function planForced(args: SubagentCommandArgs, team: TeamConfig): ForcedPlan;
109
+ /**
110
+ * The parenthesised disclosure sentence of the plan notice - why the fan-out is not what
111
+ * the user typed - or `null` when nothing was narrowed.
112
+ *
113
+ * SHARED BY `planNotice` AND `forcedCardNote` (tui-invocation-cards RV-4): the notice and
114
+ * the transcript card must word the same fact the same way, so there is exactly one
115
+ * place that decides it.
116
+ *
117
+ * TWO CEILINGS, TWO SENTENCES. `forcedPartsMax` bounds what one argument may carry and
118
+ * `team.maxSubagents` bounds what may be dispatched; naming the wrong one sends the user
119
+ * to change a number that is not the reason.
120
+ */
121
+ export declare function clampDisclosure(planned: ForcedPlan, team: TeamConfig): string | null;
109
122
  /**
110
123
  * The one line that says what the NEXT turn will do - a plan, not a result.
111
124
  *
@@ -114,3 +127,9 @@ export declare function planForced(args: SubagentCommandArgs, team: TeamConfig):
114
127
  * said "4 subagents started" would be a promise the command cannot keep.
115
128
  */
116
129
  export declare function planNotice(planned: ForcedPlan, team: TeamConfig): string;
130
+ /**
131
+ * The card's `note`: the same disclosure, as a sentence. `undefined` when nothing was
132
+ * narrowed - the "Esc stops the turn" follow-up is an operating hint the status bar
133
+ * carries, not a fact about this invocation.
134
+ */
135
+ export declare function forcedCardNote(planned: ForcedPlan, team: TeamConfig): string | undefined;
@@ -123,34 +123,56 @@ export function planForced(args, team) {
123
123
  };
124
124
  }
125
125
  /**
126
- * The one line that says what the NEXT turn will do - a plan, not a result.
126
+ * The parenthesised disclosure sentence of the plan notice - why the fan-out is not what
127
+ * the user typed - or `null` when nothing was narrowed.
127
128
  *
128
- * IT IS PRINTED BEFORE THE TURN IS SUBMITTED and it never claims children
129
- * started: forcing is instruction-level for the first turn, and a notice that
130
- * said "4 subagents started" would be a promise the command cannot keep.
129
+ * SHARED BY `planNotice` AND `forcedCardNote` (tui-invocation-cards RV-4): the notice and
130
+ * the transcript card must word the same fact the same way, so there is exactly one
131
+ * place that decides it.
132
+ *
133
+ * TWO CEILINGS, TWO SENTENCES. `forcedPartsMax` bounds what one argument may carry and
134
+ * `team.maxSubagents` bounds what may be dispatched; naming the wrong one sends the user
135
+ * to change a number that is not the reason.
131
136
  */
132
- export function planNotice(planned, team) {
133
- const noun = planned.count === 1 ? 'subagent' : 'subagents';
134
- const head = `Forcing a fan-out of ${planned.count} ${noun}`;
135
- const followUp = `${head} (max ${team.maxSubagents}) - the next turn dispatches them. `
136
- + 'Esc stops the turn.';
137
- // TWO CEILINGS, TWO SENTENCES. `forcedPartsMax` bounds what one argument may
138
- // carry and `team.maxSubagents` bounds what may be dispatched; naming the wrong
139
- // one sends the user to change a number that is not the reason.
137
+ export function clampDisclosure(planned, team) {
140
138
  if (planned.parts.length > 0) {
141
139
  if (planned.parts.length < planned.partsAdmitted) {
142
- return `${head} (asked for ${planned.partsRequested} parts; `
143
- + `team.maxSubagents is ${team.maxSubagents}).`;
140
+ return `asked for ${planned.partsRequested} parts; team.maxSubagents is ${team.maxSubagents}`;
144
141
  }
145
142
  if (planned.partsAdmitted < planned.partsRequested) {
146
- return `${head} (asked for ${planned.partsRequested} parts, `
147
- + `the first ${planned.parts.length} are used).`;
143
+ return `asked for ${planned.partsRequested} parts, the first ${planned.parts.length} are used`;
148
144
  }
149
- return followUp;
145
+ return null;
150
146
  }
151
147
  if (planned.requested > planned.count) {
152
- return `${head} (asked for ${planned.requested}; `
153
- + `team.maxSubagents is ${team.maxSubagents}).`;
148
+ return `asked for ${planned.requested}; team.maxSubagents is ${team.maxSubagents}`;
154
149
  }
155
- return followUp;
150
+ return null;
151
+ }
152
+ /**
153
+ * The one line that says what the NEXT turn will do - a plan, not a result.
154
+ *
155
+ * IT IS PRINTED BEFORE THE TURN IS SUBMITTED and it never claims children
156
+ * started: forcing is instruction-level for the first turn, and a notice that
157
+ * said "4 subagents started" would be a promise the command cannot keep.
158
+ */
159
+ export function planNotice(planned, team) {
160
+ const noun = planned.count === 1 ? 'subagent' : 'subagents';
161
+ const head = `Forcing a fan-out of ${planned.count} ${noun}`;
162
+ const disclosure = clampDisclosure(planned, team);
163
+ if (disclosure !== null)
164
+ return `${head} (${disclosure}).`;
165
+ return `${head} (max ${team.maxSubagents}) - the next turn dispatches them. `
166
+ + 'Esc stops the turn.';
167
+ }
168
+ /**
169
+ * The card's `note`: the same disclosure, as a sentence. `undefined` when nothing was
170
+ * narrowed - the "Esc stops the turn" follow-up is an operating hint the status bar
171
+ * carries, not a fact about this invocation.
172
+ */
173
+ export function forcedCardNote(planned, team) {
174
+ const disclosure = clampDisclosure(planned, team);
175
+ if (disclosure === null)
176
+ return undefined;
177
+ return `${disclosure.charAt(0).toUpperCase()}${disclosure.slice(1)}.`;
156
178
  }
package/dist/ui/App.js CHANGED
@@ -92,7 +92,8 @@ import { SettingsScreen, compactionSettingsFrom, fastSettingsFrom, } from './ove
92
92
  import { ConfirmDialog } from './overlays/ConfirmDialog.js';
93
93
  import { QuestionOverlay } from './overlays/QuestionOverlay.js';
94
94
  import { PlanReviewOverlay } from './overlays/PlanReviewOverlay.js';
95
- import { CommandRegistry, runSlashInput } from '../commands/registry.js';
95
+ import { CommandRegistry, parseSlashInput, runSlashInput, } from '../commands/registry.js';
96
+ import { buildInvocationCard, invocationCardsEnabled } from '../agent/invocation-card.js';
96
97
  import { registerBuiltinCommands } from '../commands/builtins.js';
97
98
  import { makeSkillsCommand, registerSkillCommands } from '../commands/skills.js';
98
99
  import { MODE_TOGGLE_KEYS } from '../agent/agent-mode.js';
@@ -1942,8 +1943,10 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
1942
1943
  dispatch({ type: 'taskSummaryReady', view: outcome.view });
1943
1944
  });
1944
1945
  };
1946
+ const invocationCardsOn = invocationCardsEnabled();
1945
1947
  const makeCtx = (args) => ({
1946
1948
  args,
1949
+ invocationCards: invocationCardsOn,
1947
1950
  requestCopy: copyCoordinator.requestCopy,
1948
1951
  controller,
1949
1952
  state: stateRef.current,
@@ -2121,10 +2124,17 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
2121
2124
  // must be the one that runs, whichever branch it takes.
2122
2125
  cancelFollowThrough();
2123
2126
  cancelConfirmation();
2127
+ // THE ONE PLACE A CARD IS BUILT (tui-invocation-cards I-3 / I-6): redact -> normalise ->
2128
+ // limit, with the same redactor the `Command:` echo uses. `message` below is the model's
2129
+ // input and is never touched; the card only replaces what the TRANSCRIPT shows.
2130
+ const card = invocationCardsOn && opts.invocation
2131
+ ? buildInvocationCard(opts.invocation, (text) => redactText(text, getSecrets()))
2132
+ : undefined;
2133
+ const shown = card?.commandLine ?? message;
2124
2134
  if (interactionPhase.current === 'running' || controller.isCompactionBusy?.()) {
2125
2135
  try {
2126
2136
  const queueId = controller.queueUserMessage(message);
2127
- dispatch({ type: 'steerQueued', queueId, text: message });
2137
+ dispatch({ type: 'steerQueued', queueId, text: shown, ...(card ? { invocation: card } : {}) });
2128
2138
  return { accepted: true };
2129
2139
  }
2130
2140
  catch (error) {
@@ -2143,7 +2153,10 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
2143
2153
  // the user has learned, so suppressing the fade is correct rather than
2144
2154
  // incidental — which is why the parameter is named for the CAUSE (P2-8).
2145
2155
  if (opts.userInitiated !== false) {
2146
- recordPrompt(message);
2156
+ // A card's command line was already recorded by `executeSlashInput`; the payload
2157
+ // behind it must never reach the up-arrow history (I-9: the gate is unchanged).
2158
+ if (!card)
2159
+ recordPrompt(message);
2147
2160
  // THE ONE PLACE THE NUDGE BUDGET IS REFILLED (P0-2 / I-10). Same predicate
2148
2161
  // as `recordPrompt`, and for the same reason: the question is "did a human
2149
2162
  // ask for this?", and only a human may hand the gate another attempt. A
@@ -2154,7 +2167,7 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
2154
2167
  // Mirrors where the reducer clears its own copies, in the same action
2155
2168
  // (C-11): a new turn's end reason must not inherit the previous one's.
2156
2169
  endReasonRef.current = { aborted: false, errored: false };
2157
- dispatch({ type: 'submit', text: message });
2170
+ dispatch({ type: 'submit', text: shown, ...(card ? { invocation: card } : {}) });
2158
2171
  const pre = controller.preflight();
2159
2172
  if (!pre.ok) {
2160
2173
  dispatch({ type: 'runError' });
@@ -2174,7 +2187,7 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
2174
2187
  // model call that describes a turn nobody ran; and below, in the queueing
2175
2188
  // branch, the message is steering rather than a new task.
2176
2189
  if (opts.userInitiated !== false)
2177
- ensureTaskSummary(message);
2190
+ ensureTaskSummary(shown);
2178
2191
  void controller.prompt(message, { todoPolicy: opts.todoPolicy ?? 'new-task' }).then((outcome) => {
2179
2192
  if (!mounted.current || sequence !== submissionSequence.current)
2180
2193
  return;
@@ -2213,7 +2226,13 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
2213
2226
  // Retain a recoverable command before any asynchronous handler can fail.
2214
2227
  const safe = redactText(raw, getSecrets());
2215
2228
  recordPrompt(safe);
2216
- notify('info', `Command: ${safe}`);
2229
+ // The echo is suppressed BEFORE the command runs and never deleted afterwards: printed
2230
+ // scrollback rows cannot be rewritten (I-5). The predicate is the command's own (I-8).
2231
+ const parsed = parseSlashInput(raw);
2232
+ const rendersCard = invocationCardsOn && parsed !== null
2233
+ && registry.get(parsed.name)?.rendersInvocationCard?.(parsed.args) === true;
2234
+ if (!rendersCard)
2235
+ notify('info', `Command: ${safe}`);
2217
2236
  try {
2218
2237
  await runSlashInput(registry, raw, makeCtx);
2219
2238
  }
@@ -8,6 +8,7 @@ import { pickGlyphs, toolGlyph } from './glyphs.js';
8
8
  import { separationRows } from './density.js';
9
9
  import { EntryFrame } from './entries/EntryFrame.js';
10
10
  import { UserEntry } from './entries/UserEntry.js';
11
+ import { InvocationCard } from './entries/InvocationCard.js';
11
12
  import { QueuedEntry } from './entries/QueuedEntry.js';
12
13
  import { AssistantEntry } from './entries/AssistantEntry.js';
13
14
  import { ToolCard, statusColor, toolEntryGlyph } from './entries/ToolCard.js';
@@ -78,7 +79,9 @@ function EntryViewImpl({ entry, prev, expanded, thinkingVisible, reducedMotion,
78
79
  const frame = (glyph, color, children) => (_jsx(EntryFrame, { glyph: glyph, color: color, continuation: glyphs.railVertical, separation: separation, spacerColor: theme.border, children: children }));
79
80
  switch (entry.kind) {
80
81
  case 'user':
81
- return frame(glyphs.user, theme.user, _jsx(UserEntry, { text: entry.text, theme: theme, caps: caps }));
82
+ return frame(glyphs.user, theme.user, entry.invocation
83
+ ? _jsx(InvocationCard, { card: entry.invocation, theme: theme, caps: caps })
84
+ : _jsx(UserEntry, { text: entry.text, theme: theme, caps: caps }));
82
85
  case 'queued':
83
86
  // `theme.muted`: quieter than a user message, louder than a notice --
84
87
  // the row exists to be noticed UNTIL it is accepted, then rewritten in
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Render a `user` entry that carries an invocation card (tui-invocation-cards).
3
+ *
4
+ * Like `UserEntry`, this renders CONTENT only: the role glyph, rail and spacing belong
5
+ * to `EntryFrame`, so the card stays "the user's turn" for density, turn counting and
6
+ * the settled boundary. The header text comes from `invocationHeaderSegments` and is
7
+ * never assembled here (RV-10); the row count it implies is `invocationCardRows`.
8
+ */
9
+ import React from 'react';
10
+ import type { Theme } from '../theme.js';
11
+ import type { TermCapabilities } from '../capabilities.js';
12
+ import type { InvocationCard as InvocationCardData } from '../../agent/invocation-card.js';
13
+ declare function InvocationCardImpl({ card, theme, caps, }: {
14
+ card: InvocationCardData;
15
+ theme: Theme;
16
+ caps: TermCapabilities;
17
+ }): React.ReactElement;
18
+ /** Default comparator: `mapEntry` preserves identity for untouched entries (L2 / R3). */
19
+ export declare const InvocationCard: React.MemoExoticComponent<typeof InvocationCardImpl>;
20
+ export {};
@@ -0,0 +1,32 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /**
3
+ * Render a `user` entry that carries an invocation card (tui-invocation-cards).
4
+ *
5
+ * Like `UserEntry`, this renders CONTENT only: the role glyph, rail and spacing belong
6
+ * to `EntryFrame`, so the card stays "the user's turn" for density, turn counting and
7
+ * the settled boundary. The header text comes from `invocationHeaderSegments` and is
8
+ * never assembled here (RV-10); the row count it implies is `invocationCardRows`.
9
+ */
10
+ import React from 'react';
11
+ import { Box, Text } from 'ink';
12
+ import { pickGlyphs } from '../glyphs.js';
13
+ import { invocationHeaderSegments, invocationHiddenText, invocationInputText, } from '../layout/invocation-card.js';
14
+ import { GUTTER_WIDTH } from '../layout/Gutter.js';
15
+ import { useTerminalSize } from '../layout/useTerminalSize.js';
16
+ import { fitCells } from '../layout/cells.js';
17
+ function toneColor(tone, theme) {
18
+ if (tone === 'label')
19
+ return theme.accent;
20
+ if (tone === 'name')
21
+ return theme.user;
22
+ return theme.muted;
23
+ }
24
+ function InvocationCardImpl({ card, theme, caps, }) {
25
+ const glyphs = pickGlyphs(caps);
26
+ const { cols } = useTerminalSize();
27
+ const usable = Math.max(1, cols - GUTTER_WIDTH);
28
+ const header = invocationHeaderSegments(card, glyphs, usable);
29
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { wrap: "truncate", children: header.map((part, index) => (_jsx(Text, { color: toneColor(part.tone, theme), bold: part.bold, children: part.text }, index))) }), card.input !== '' && _jsx(Text, { color: theme.user, children: invocationInputText(card) }), card.inputHidden > 0 && (_jsx(Text, { color: theme.muted, wrap: "truncate", children: invocationHiddenText(card, glyphs) })), card.note !== undefined && card.note !== '' && (_jsx(Text, { color: theme.muted, wrap: "truncate", children: fitCells(card.note, usable, glyphs.ellipsis) }))] }));
30
+ }
31
+ /** Default comparator: `mapEntry` preserves identity for untouched entries (L2 / R3). */
32
+ export const InvocationCard = React.memo(InvocationCardImpl);
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Invocation card layout (tui-invocation-cards section 3.7): what a `user` entry that
3
+ * carries an `invocation` says, as pure data. No Ink, no React. ASCII ONLY.
4
+ *
5
+ * THE CARD'S HEIGHT IS A FUNCTION OF ITS DATA AND THE USABLE WIDTH (I-4).
6
+ * `InvocationCard.tsx` renders exactly the rows `invocationCardRows` counts, and both
7
+ * read `INVOCATION_LIMITS` plus the one `invocationInputText` below - an estimate that
8
+ * disagrees with the painter is permanent, because an over-estimated entry is never
9
+ * mounted and so never measured (see `virtual-window.ts`).
10
+ */
11
+ import type { InvocationCard } from '../../agent/invocation-card.js';
12
+ import type { Glyphs } from '../glyphs.js';
13
+ /** Colour slots the component maps onto the theme; kept local so this file needs no `Theme`. */
14
+ export type InvocationTone = 'label' | 'name' | 'muted';
15
+ export interface InvocationSegment {
16
+ text: string;
17
+ tone: InvocationTone;
18
+ bold: boolean;
19
+ }
20
+ /**
21
+ * The header row, already fitted to `usable` columns (RV-10). Narrow terminals drop
22
+ * facts from the right WHOLE before anything else is cut; only when the identity alone
23
+ * is too wide is the name shortened. Ink's `wrap="truncate"` stays on the row as a
24
+ * backstop, but it is never what produces the visible ellipsis.
25
+ */
26
+ export declare function invocationHeaderSegments(card: InvocationCard, glyphs: Glyphs, usable: number): InvocationSegment[];
27
+ /** The text painted for `input`: tabs expanded per line, the same rule `UserEntry` follows. */
28
+ export declare function invocationInputText(card: InvocationCard): string;
29
+ /** Singular/plural for the hidden-characters tail. */
30
+ export declare function invocationHiddenText(card: InvocationCard, glyphs: Glyphs): string;
31
+ /**
32
+ * Rows the card occupies at `usable` columns. `wrappedRows` is injected by the caller
33
+ * (`virtual-window.ts` owns the one wrapping rule) so this file stays import-cycle free.
34
+ */
35
+ export declare function invocationCardRows(card: InvocationCard, usable: number, wrappedRows: (text: string, usable: number) => number): number;
36
+ /**
37
+ * The lines the exit replay / export prints UNDER the command line (section 2.4):
38
+ * a bracketed fact line, and the disclosure note when there is one. Plain ASCII
39
+ * separators - the replay is pasted into issues and must survive any terminal.
40
+ */
41
+ export declare function invocationCardLines(card: InvocationCard): string[];
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Invocation card layout (tui-invocation-cards section 3.7): what a `user` entry that
3
+ * carries an `invocation` says, as pure data. No Ink, no React. ASCII ONLY.
4
+ *
5
+ * THE CARD'S HEIGHT IS A FUNCTION OF ITS DATA AND THE USABLE WIDTH (I-4).
6
+ * `InvocationCard.tsx` renders exactly the rows `invocationCardRows` counts, and both
7
+ * read `INVOCATION_LIMITS` plus the one `invocationInputText` below - an estimate that
8
+ * disagrees with the painter is permanent, because an over-estimated entry is never
9
+ * mounted and so never measured (see `virtual-window.ts`).
10
+ */
11
+ import { expandTabs } from '../display-tab.js';
12
+ import { cellWidth, fitCells } from './cells.js';
13
+ /** Two spaces between the identity (`skill . pdf-forms`) and the facts. */
14
+ const FACT_GAP = ' ';
15
+ function seg(text, tone, bold = false) {
16
+ return { text, tone, bold };
17
+ }
18
+ function segmentsWidth(segments) {
19
+ return segments.reduce((sum, part) => sum + cellWidth(part.text), 0);
20
+ }
21
+ function identitySegments(card, glyphs, name) {
22
+ const parts = [seg(card.variant, 'label', true)];
23
+ if (name !== '')
24
+ parts.push(seg(` ${glyphs.midDot} `, 'muted'), seg(name, 'name', true));
25
+ return parts;
26
+ }
27
+ function factSegments(facts, glyphs) {
28
+ if (facts.length === 0)
29
+ return [];
30
+ const parts = [seg(FACT_GAP, 'muted')];
31
+ facts.forEach((fact, index) => {
32
+ if (index > 0)
33
+ parts.push(seg(` ${glyphs.midDot} `, 'muted'));
34
+ parts.push(seg(fact, 'muted'));
35
+ });
36
+ return parts;
37
+ }
38
+ /**
39
+ * The header row, already fitted to `usable` columns (RV-10). Narrow terminals drop
40
+ * facts from the right WHOLE before anything else is cut; only when the identity alone
41
+ * is too wide is the name shortened. Ink's `wrap="truncate"` stays on the row as a
42
+ * backstop, but it is never what produces the visible ellipsis.
43
+ */
44
+ export function invocationHeaderSegments(card, glyphs, usable) {
45
+ const width = Math.max(1, Math.floor(usable));
46
+ for (let kept = card.facts.length; kept >= 0; kept -= 1) {
47
+ const candidate = [
48
+ ...identitySegments(card, glyphs, card.name),
49
+ ...factSegments(card.facts.slice(0, kept), glyphs),
50
+ ];
51
+ if (segmentsWidth(candidate) <= width)
52
+ return candidate;
53
+ }
54
+ const head = identitySegments(card, glyphs, '');
55
+ const room = width - segmentsWidth(head) - cellWidth(` ${glyphs.midDot} `);
56
+ if (card.name !== '' && room > 1) {
57
+ return identitySegments(card, glyphs, fitCells(card.name, room, glyphs.ellipsis));
58
+ }
59
+ return [seg(fitCells(card.variant, width, glyphs.ellipsis), 'label', true)];
60
+ }
61
+ /** The text painted for `input`: tabs expanded per line, the same rule `UserEntry` follows. */
62
+ export function invocationInputText(card) {
63
+ return card.input.split('\n').map((line) => expandTabs(line)).join('\n');
64
+ }
65
+ /** Singular/plural for the hidden-characters tail. */
66
+ export function invocationHiddenText(card, glyphs) {
67
+ const unit = card.inputHidden === 1 ? 'char' : 'chars';
68
+ return `${glyphs.ellipsis} +${card.inputHidden} ${unit} not shown`;
69
+ }
70
+ /**
71
+ * Rows the card occupies at `usable` columns. `wrappedRows` is injected by the caller
72
+ * (`virtual-window.ts` owns the one wrapping rule) so this file stays import-cycle free.
73
+ */
74
+ export function invocationCardRows(card, usable, wrappedRows) {
75
+ const inputRows = card.input === '' ? 0 : wrappedRows(invocationInputText(card), usable);
76
+ return 1 + inputRows + (card.inputHidden > 0 ? 1 : 0) + (card.note ? 1 : 0);
77
+ }
78
+ const REPLAY_INDENT = ' ';
79
+ /**
80
+ * The lines the exit replay / export prints UNDER the command line (section 2.4):
81
+ * a bracketed fact line, and the disclosure note when there is one. Plain ASCII
82
+ * separators - the replay is pasted into issues and must survive any terminal.
83
+ */
84
+ export function invocationCardLines(card) {
85
+ const identity = card.name === '' ? card.variant : `${card.variant} ${card.name}`;
86
+ const detail = card.facts.length === 0 ? identity : `${identity} ${card.facts.join(' - ')}`;
87
+ const lines = [`${REPLAY_INDENT}[${detail}]`];
88
+ if (card.note)
89
+ lines.push(`${REPLAY_INDENT}${card.note}`);
90
+ return lines;
91
+ }
@@ -40,6 +40,7 @@ import { GUTTER_WIDTH } from './Gutter.js';
40
40
  import { expandTabs } from '../display-tab.js';
41
41
  import { USER_ENTRY_MAX_ROWS } from '../composer-limits.js';
42
42
  import { isLiveTeamCard } from '../team-status.js';
43
+ import { invocationCardRows } from './invocation-card.js';
43
44
  export const VIRTUAL_LIMITS = {
44
45
  /**
45
46
  * Entries kept mounted on each side of the visible band.
@@ -68,8 +69,12 @@ export const VIRTUAL_LIMITS = {
68
69
  */
69
70
  export function entryRevision(entry) {
70
71
  switch (entry.kind) {
72
+ // A card's shape is fixed at creation; the suffix only tells "card" from "plain" (and
73
+ // the three row-bearing parts), which is what makes a `queued -> user` rewrite re-measure.
71
74
  case 'user':
72
- return 'u';
75
+ return entry.invocation
76
+ ? `i${entry.invocation.input.length}.${entry.invocation.inputHidden > 0 ? 1 : 0}.${entry.invocation.note ? 1 : 0}`
77
+ : 'u';
73
78
  case 'notice':
74
79
  return 'n';
75
80
  // `thinkingMs` is O(1) and it CHANGES THE ROW: sealing the clock turns
@@ -259,6 +264,11 @@ export function estimateEntryRows(entry, cols, density, expanded, thinkingVisibl
259
264
  const separation = density === 'comfortable' ? 1 : 0;
260
265
  switch (entry.kind) {
261
266
  case 'user':
267
+ // A CARD HAS ITS OWN, BOUNDED HEIGHT (tui-invocation-cards I-4): the same
268
+ // `invocationCardRows` the painter is built from, never the command line's wrapped rows.
269
+ if (entry.invocation) {
270
+ return separation + invocationCardRows(entry.invocation, usable, wrappedRows);
271
+ }
262
272
  // MIRRORS `UserEntry`'s RENDER CAP (+1 for the `... +N more lines` tail).
263
273
  // THESE TWO NUMBERS ARE ONE NUMBER (I-13). Capping the renderer alone
264
274
  // would leave this function believing a 5 000-line message is 5 000 rows
@@ -3,6 +3,7 @@ import { formatRetryLine } from '../agent/retry-view.js';
3
3
  import { childStatus, outcomePhrase, rosterCounts, statusMarkerCell } from './team-status.js';
4
4
  import { expandTabs } from './display-tab.js';
5
5
  import { governanceCardLines } from './layout/governance-card.js';
6
+ import { invocationCardLines } from './layout/invocation-card.js';
6
7
  const DEFAULT_MAX_ENTRIES = 200;
7
8
  const CONTINUATION_INDENT = ' ';
8
9
  const TOOL_INDENT = ' ';
@@ -87,7 +88,9 @@ function renderEntry(entry, glyphs) {
87
88
  case 'queued':
88
89
  return block('Queued but never sent:', entry.text);
89
90
  case 'user':
90
- return block(glyphs.user, entry.text);
91
+ return entry.invocation
92
+ ? [...block(glyphs.user, entry.text), ...invocationCardLines(entry.invocation)]
93
+ : block(glyphs.user, entry.text);
91
94
  case 'assistant': {
92
95
  const suffix = entry.aborted ? ' (aborted)' : '';
93
96
  return block(glyphs.assistant, `${entry.text}${suffix}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aragon-agent/cli",
3
- "version": "0.6.45",
3
+ "version": "0.6.46",
4
4
  "description": "AragonAgent CLI — a Claude-Code / Codex-style interactive terminal UI (TUI) on top of the @aragon-agent/core engine. Installable and runnable via npm / npx.",
5
5
  "license": "MIT",
6
6
  "author": "AragonAgent contributors",
@@ -56,7 +56,7 @@
56
56
  "prepublishOnly": "npm run build"
57
57
  },
58
58
  "dependencies": {
59
- "@aragon-agent/core": "^0.2.57",
59
+ "@aragon-agent/core": "^0.2.58",
60
60
  "chalk": "^5.6.2",
61
61
  "cli-highlight": "^2.0.0",
62
62
  "commander": "^12.1.0",