@stigmer/react 3.12.2 → 3.12.3

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 (151) hide show
  1. package/access/ManageAccessDialog.d.ts.map +1 -1
  2. package/access/ManageAccessDialog.js +3 -19
  3. package/access/ManageAccessDialog.js.map +1 -1
  4. package/agent-instance/CreateAgentInstanceDialog.d.ts.map +1 -1
  5. package/agent-instance/CreateAgentInstanceDialog.js +7 -20
  6. package/agent-instance/CreateAgentInstanceDialog.js.map +1 -1
  7. package/attachment/AttachmentImageLightbox.d.ts.map +1 -1
  8. package/attachment/AttachmentImageLightbox.js +8 -23
  9. package/attachment/AttachmentImageLightbox.js.map +1 -1
  10. package/channel/ChannelConversationsDialog.d.ts.map +1 -1
  11. package/channel/ChannelConversationsDialog.js +3 -19
  12. package/channel/ChannelConversationsDialog.js.map +1 -1
  13. package/channel/ChannelCredentialsDialog.d.ts.map +1 -1
  14. package/channel/ChannelCredentialsDialog.js +3 -19
  15. package/channel/ChannelCredentialsDialog.js.map +1 -1
  16. package/channel/ChannelTemplatesDialog.d.ts.map +1 -1
  17. package/channel/ChannelTemplatesDialog.js +4 -20
  18. package/channel/ChannelTemplatesDialog.js.map +1 -1
  19. package/channel/ConnectSlackDialog.d.ts.map +1 -1
  20. package/channel/ConnectSlackDialog.js +3 -21
  21. package/channel/ConnectSlackDialog.js.map +1 -1
  22. package/channel/ConnectWhatsAppDialog.d.ts.map +1 -1
  23. package/channel/ConnectWhatsAppDialog.js +3 -21
  24. package/channel/ConnectWhatsAppDialog.js.map +1 -1
  25. package/conversation/ConversationAttentionBanner.d.ts +23 -6
  26. package/conversation/ConversationAttentionBanner.d.ts.map +1 -1
  27. package/conversation/ConversationAttentionBanner.js +25 -10
  28. package/conversation/ConversationAttentionBanner.js.map +1 -1
  29. package/conversation/ConversationListPane.d.ts.map +1 -1
  30. package/conversation/ConversationListPane.js +25 -6
  31. package/conversation/ConversationListPane.js.map +1 -1
  32. package/conversation/ConversationsWorkbench.d.ts.map +1 -1
  33. package/conversation/ConversationsWorkbench.js +1 -1
  34. package/conversation/ConversationsWorkbench.js.map +1 -1
  35. package/execution/ApprovalCard.d.ts.map +1 -1
  36. package/execution/ApprovalCard.js +13 -3
  37. package/execution/ApprovalCard.js.map +1 -1
  38. package/execution/ArtifactPreviewModal.d.ts.map +1 -1
  39. package/execution/ArtifactPreviewModal.js +11 -17
  40. package/execution/ArtifactPreviewModal.js.map +1 -1
  41. package/execution/ToolCallItem.d.ts +3 -1
  42. package/execution/ToolCallItem.d.ts.map +1 -1
  43. package/execution/ToolCallItem.js +19 -7
  44. package/execution/ToolCallItem.js.map +1 -1
  45. package/execution/index.d.ts +1 -1
  46. package/execution/index.d.ts.map +1 -1
  47. package/execution/index.js +1 -1
  48. package/execution/index.js.map +1 -1
  49. package/execution/tool-categories.d.ts +51 -0
  50. package/execution/tool-categories.d.ts.map +1 -1
  51. package/execution/tool-categories.js +103 -1
  52. package/execution/tool-categories.js.map +1 -1
  53. package/execution/tool-presenter.d.ts +15 -0
  54. package/execution/tool-presenter.d.ts.map +1 -1
  55. package/execution/tool-presenter.js +7 -2
  56. package/execution/tool-presenter.js.map +1 -1
  57. package/index.d.ts +1 -1
  58. package/index.d.ts.map +1 -1
  59. package/index.js +1 -1
  60. package/index.js.map +1 -1
  61. package/internal/DialogShell.d.ts +77 -0
  62. package/internal/DialogShell.d.ts.map +1 -0
  63. package/internal/DialogShell.js +77 -0
  64. package/internal/DialogShell.js.map +1 -0
  65. package/manifest/ApplyManifestDialog.d.ts.map +1 -1
  66. package/manifest/ApplyManifestDialog.js +7 -17
  67. package/manifest/ApplyManifestDialog.js.map +1 -1
  68. package/manifest/EditResourceYamlDialog.d.ts.map +1 -1
  69. package/manifest/EditResourceYamlDialog.js +3 -19
  70. package/manifest/EditResourceYamlDialog.js.map +1 -1
  71. package/mcp-server/McpServerConnectDialog.d.ts.map +1 -1
  72. package/mcp-server/McpServerConnectDialog.js +8 -20
  73. package/mcp-server/McpServerConnectDialog.js.map +1 -1
  74. package/mcp-server/McpServerDetailView.d.ts.map +1 -1
  75. package/mcp-server/McpServerDetailView.js +4 -15
  76. package/mcp-server/McpServerDetailView.js.map +1 -1
  77. package/package.json +4 -4
  78. package/resource-detail/ConfirmDialog.d.ts +2 -3
  79. package/resource-detail/ConfirmDialog.d.ts.map +1 -1
  80. package/resource-detail/ConfirmDialog.js +8 -20
  81. package/resource-detail/ConfirmDialog.js.map +1 -1
  82. package/sharing/ShareAgentDialog.d.ts.map +1 -1
  83. package/sharing/ShareAgentDialog.js +2 -20
  84. package/sharing/ShareAgentDialog.js.map +1 -1
  85. package/skill/SkillDiffDialog.d.ts.map +1 -1
  86. package/skill/SkillDiffDialog.js +5 -21
  87. package/skill/SkillDiffDialog.js.map +1 -1
  88. package/src/access/ManageAccessDialog.tsx +7 -27
  89. package/src/agent-instance/CreateAgentInstanceDialog.tsx +14 -27
  90. package/src/attachment/AttachmentImageLightbox.tsx +13 -36
  91. package/src/channel/ChannelConversationsDialog.tsx +8 -27
  92. package/src/channel/ChannelCredentialsDialog.tsx +8 -28
  93. package/src/channel/ChannelTemplatesDialog.tsx +10 -29
  94. package/src/channel/ConnectSlackDialog.tsx +8 -30
  95. package/src/channel/ConnectWhatsAppDialog.tsx +8 -30
  96. package/src/conversation/ConversationAttentionBanner.tsx +64 -19
  97. package/src/conversation/ConversationListPane.tsx +42 -17
  98. package/src/conversation/ConversationsWorkbench.tsx +1 -0
  99. package/src/conversation/__tests__/ConversationAttentionBanner.test.tsx +76 -2
  100. package/src/conversation/__tests__/ConversationListPane.test.tsx +15 -4
  101. package/src/execution/ApprovalCard.tsx +23 -2
  102. package/src/execution/ArtifactPreviewModal.tsx +15 -27
  103. package/src/execution/ToolCallItem.tsx +19 -8
  104. package/src/execution/__tests__/intent-title.test.tsx +221 -0
  105. package/src/execution/index.ts +3 -0
  106. package/src/execution/tool-categories.ts +123 -1
  107. package/src/execution/tool-presenter.ts +22 -1
  108. package/src/index.ts +3 -0
  109. package/src/internal/DialogShell.tsx +169 -0
  110. package/src/internal/__tests__/DialogShell.test.tsx +223 -0
  111. package/src/manifest/ApplyManifestDialog.tsx +8 -26
  112. package/src/manifest/EditResourceYamlDialog.tsx +4 -31
  113. package/src/mcp-server/McpServerConnectDialog.tsx +15 -29
  114. package/src/mcp-server/McpServerDetailView.tsx +12 -24
  115. package/src/resource-detail/ConfirmDialog.tsx +9 -30
  116. package/src/sharing/ShareAgentDialog.tsx +7 -29
  117. package/src/skill/SkillDiffDialog.tsx +13 -36
  118. package/src/workflow/ViewYamlDialog.tsx +18 -37
  119. package/src/workflow/WorkflowArchitectDialog.tsx +11 -43
  120. package/src/workflow/WorkflowExplainDialog.tsx +11 -37
  121. package/src/workflow/WorkflowGraphFullscreenDialog.tsx +14 -26
  122. package/src/workflow/WorkflowRunDialog.tsx +21 -48
  123. package/src/workflow/execution-comparison/ExecutionComparisonPicker.tsx +14 -31
  124. package/src/workflow/instance/CreateWorkflowInstanceDialog.tsx +14 -27
  125. package/src/workflow/templates/WorkflowTemplatePreview.tsx +18 -28
  126. package/styles.css +1 -1
  127. package/workflow/ViewYamlDialog.d.ts.map +1 -1
  128. package/workflow/ViewYamlDialog.js +10 -29
  129. package/workflow/ViewYamlDialog.js.map +1 -1
  130. package/workflow/WorkflowArchitectDialog.d.ts.map +1 -1
  131. package/workflow/WorkflowArchitectDialog.js +5 -24
  132. package/workflow/WorkflowArchitectDialog.js.map +1 -1
  133. package/workflow/WorkflowExplainDialog.d.ts.map +1 -1
  134. package/workflow/WorkflowExplainDialog.js +5 -21
  135. package/workflow/WorkflowExplainDialog.js.map +1 -1
  136. package/workflow/WorkflowGraphFullscreenDialog.d.ts.map +1 -1
  137. package/workflow/WorkflowGraphFullscreenDialog.js +11 -17
  138. package/workflow/WorkflowGraphFullscreenDialog.js.map +1 -1
  139. package/workflow/WorkflowRunDialog.d.ts.map +1 -1
  140. package/workflow/WorkflowRunDialog.js +13 -29
  141. package/workflow/WorkflowRunDialog.js.map +1 -1
  142. package/workflow/execution-comparison/ExecutionComparisonPicker.d.ts.map +1 -1
  143. package/workflow/execution-comparison/ExecutionComparisonPicker.js +8 -26
  144. package/workflow/execution-comparison/ExecutionComparisonPicker.js.map +1 -1
  145. package/workflow/instance/CreateWorkflowInstanceDialog.d.ts.map +1 -1
  146. package/workflow/instance/CreateWorkflowInstanceDialog.js +7 -20
  147. package/workflow/instance/CreateWorkflowInstanceDialog.js.map +1 -1
  148. package/workflow/templates/WorkflowTemplatePreview.d.ts +1 -1
  149. package/workflow/templates/WorkflowTemplatePreview.d.ts.map +1 -1
  150. package/workflow/templates/WorkflowTemplatePreview.js +10 -22
  151. package/workflow/templates/WorkflowTemplatePreview.js.map +1 -1
@@ -12,6 +12,7 @@ import {
12
12
  import {
13
13
  resolveToolCategoryFromKind,
14
14
  extractPrimaryArgFromPreview,
15
+ extractShellIntentFromPreview,
15
16
  extractWriteContentFromPreview,
16
17
  isFileCategory,
17
18
  type ToolCategory,
@@ -160,6 +161,26 @@ export function ApprovalCardHeader({
160
161
  ],
161
162
  );
162
163
 
164
+ // A shell gate whose call carries a model-authored intent phrase titles
165
+ // the header with it (stigmer#276) — supplementary context only: the
166
+ // command stays foregrounded in the body's terminal preview, which remains
167
+ // the content the user is approving.
168
+ const intent = useMemo(
169
+ () =>
170
+ extractShellIntentFromPreview(
171
+ pendingApproval.toolName,
172
+ pendingApproval.argsPreview,
173
+ pendingApproval.mcpServerSlug,
174
+ pendingApproval.toolKind,
175
+ ),
176
+ [
177
+ pendingApproval.toolName,
178
+ pendingApproval.argsPreview,
179
+ pendingApproval.mcpServerSlug,
180
+ pendingApproval.toolKind,
181
+ ],
182
+ );
183
+
163
184
  return (
164
185
  <div
165
186
  className={cn(
@@ -173,7 +194,7 @@ export function ApprovalCardHeader({
173
194
 
174
195
  <span className="stg:min-w-0 stg:flex-1 stg:flex stg:items-center stg:gap-1.5 stg:overflow-hidden">
175
196
  <span className="stg:shrink-0 stg:font-medium stg:text-foreground">
176
- {categoryInfo.label}
197
+ {intent ?? categoryInfo.label}
177
198
  </span>
178
199
  {primaryArg &&
179
200
  categoryInfo.category !== "shell" &&
@@ -186,7 +207,7 @@ export function ApprovalCardHeader({
186
207
  />
187
208
  ) : (
188
209
  // Shell is the exception: its command is shown in the body's
189
- // terminal session, so the header stays minimal (icon + label).
210
+ // terminal session, so the header stays minimal (icon + title).
190
211
  <span className="stg:min-w-0 stg:truncate stg:font-mono stg:text-muted-foreground">
191
212
  {primaryArg}
192
213
  </span>
@@ -1,8 +1,9 @@
1
1
  "use client";
2
2
 
3
- import { useCallback, useEffect, useRef } from "react";
3
+ import { useCallback } from "react";
4
4
  import type { ExecutionArtifact } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/artifact_pb";
5
5
  import { cn } from "@stigmer/theme";
6
+ import { DialogShell } from "../internal/DialogShell.js";
6
7
  import { useArtifactDownload } from "./useArtifactDownload.js";
7
8
  import { formatArtifactSize } from "./artifact-utils.js";
8
9
  import { ArtifactContentBody } from "./ArtifactContentBody.js";
@@ -253,37 +254,24 @@ export function ArtifactPreviewModal({
253
254
  onImplement,
254
255
  className,
255
256
  }: ArtifactPreviewModalProps) {
256
- const dialogRef = useRef<HTMLDialogElement>(null);
257
-
258
- useEffect(() => {
259
- const dialog = dialogRef.current;
260
- if (!dialog) return;
261
-
262
- if (open && !dialog.open) {
263
- dialog.showModal();
264
- } else if (!open && dialog.open) {
265
- dialog.close();
266
- }
267
- }, [open]);
268
-
269
- const handleCancel = useCallback(
270
- (e: React.SyntheticEvent) => {
271
- e.preventDefault();
272
- onClose();
257
+ const handleOpenChange = useCallback(
258
+ (nextOpen: boolean) => {
259
+ if (!nextOpen) onClose();
273
260
  },
274
261
  [onClose],
275
262
  );
276
263
 
277
264
  return (
278
- <dialog
279
- ref={dialogRef}
280
- onCancel={handleCancel}
265
+ <DialogShell
266
+ open={open}
267
+ onOpenChange={handleOpenChange}
268
+ width="3xl"
281
269
  aria-label={`Preview ${artifact.name}`}
282
- className={cn(
283
- "stg:fixed stg:inset-0 stg:m-auto stg:w-full stg:max-w-3xl stg:rounded-lg stg:border stg:border-border stg:bg-background stg:p-0 stg:text-foreground stg:shadow-lg stg:outline-none",
284
- "stg:[&::backdrop]:bg-black/50",
285
- className,
286
- )}
270
+ // Background surface is deliberate for content previews. The
271
+ // previously hardcoded ::backdrop color (bg-black/50 it escaped the
272
+ // #652 fence via the arbitrary-variant spelling) now rides the
273
+ // shell's token backdrop.
274
+ className={cn("stg:bg-background stg:text-foreground stg:outline-none", className)}
287
275
  >
288
276
  {open && (
289
277
  <ArtifactPreviewContent
@@ -296,7 +284,7 @@ export function ArtifactPreviewModal({
296
284
  onImplement={onImplement}
297
285
  />
298
286
  )}
299
- </dialog>
287
+ </DialogShell>
300
288
  );
301
289
  }
302
290
 
@@ -105,7 +105,9 @@ export interface ToolCallItemProps {
105
105
  *
106
106
  * Shows category-aware labels (e.g., "Shell", "Read", "Edit") with
107
107
  * the primary argument as a subtitle, a category-specific icon, and
108
- * an inline approval decision badge when applicable.
108
+ * an inline approval decision badge when applicable. A shell row whose
109
+ * call carries a model-authored intent phrase is titled with that phrase
110
+ * instead — the command becomes the subtitle (stigmer#276).
109
111
  *
110
112
  * Wrapped in `React.memo` — structural sharing (DD-009/010) preserves
111
113
  * the `ToolCall` reference when unchanged, so a settled row skips
@@ -138,7 +140,7 @@ export const ToolCallItem = memo(function ToolCallItem({
138
140
  const duration = formatHeaderDuration(toolCall.startedAt, toolCall.completedAt);
139
141
  const isSubAgent = subAgentExecution != null;
140
142
 
141
- const { category, label, primaryArg, result, resultSummary, disclosure, chrome } =
143
+ const { category, label, intent, title, primaryArg, result, resultSummary, disclosure, chrome } =
142
144
  useToolPresentation(toolCall);
143
145
  const CategoryIcon = CATEGORY_ICON[category];
144
146
 
@@ -173,9 +175,12 @@ export const ToolCallItem = memo(function ToolCallItem({
173
175
  initialOpen: defaultExpanded || autoOpen,
174
176
  });
175
177
 
178
+ // Sub-agents title the row with their subject; every other row titles with
179
+ // the resolved presentation `title` (host label override > model-authored
180
+ // shell intent > category label, stigmer#276).
176
181
  const displayLabel = isSubAgent
177
182
  ? subAgentExecution.subject || subAgentExecution.name || label
178
- : label;
183
+ : title;
179
184
 
180
185
  const approvalBadge = getApprovalBadge(toolCall);
181
186
 
@@ -308,11 +313,17 @@ export const ToolCallItem = memo(function ToolCallItem({
308
313
  );
309
314
  }
310
315
 
311
- // Shell shows neither a command subtitle nor an exit summary in the header —
312
- // both live in the terminal session body below, so the header stays minimal
313
- // (icon + label + status + duration).
314
- const displaySubtitle =
315
- isSubAgent || category === "shell" ? null : primaryArg;
316
+ // Shell headers stay minimal (icon + label + status + duration) the
317
+ // command and exit summary live in the terminal session body EXCEPT when
318
+ // the model authored an intent title (stigmer#276): the title then says
319
+ // *why*, so the command surfaces as the secondary text and a fully
320
+ // collapsed row still reads like a narrated log. Intent-less shell rows
321
+ // render byte-identically to before.
322
+ const displaySubtitle = isSubAgent
323
+ ? null
324
+ : category === "shell"
325
+ ? (intent ? primaryArg : null)
326
+ : primaryArg;
316
327
 
317
328
  // For file tools the subtitle is a path: render it filename-first through
318
329
  // FilePathLink (full path on hover) rather than the raw, often-absolute string
@@ -0,0 +1,221 @@
1
+ /**
2
+ * Model-authored intent titles for shell tool calls (stigmer#276).
3
+ *
4
+ * The extraction contract is pinned by the cross-surface fixture
5
+ * test/fixtures/tool-view/intent-title.json — the same file the runner's
6
+ * tool-intent middleware tests assert against, so the wire key and the
7
+ * kind-scoping can never drift between the writer and the readers.
8
+ */
9
+
10
+ import { readFileSync } from "node:fs";
11
+ import { dirname, resolve } from "node:path";
12
+ import { fileURLToPath } from "node:url";
13
+ import { describe, it, expect, afterEach } from "vitest";
14
+ import { render, screen, cleanup } from "@testing-library/react";
15
+ import { renderHook } from "@testing-library/react";
16
+ import { create, type JsonObject } from "@bufbuild/protobuf";
17
+ import {
18
+ ToolCallSchema,
19
+ type ToolCall,
20
+ } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/message_pb";
21
+ import { PendingApprovalSchema } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/approval_pb";
22
+ import { ToolCallStatus, ToolKind } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
23
+ import {
24
+ extractShellIntent,
25
+ extractShellIntentFromPreview,
26
+ SHELL_INTENT_ARG_FIELD,
27
+ } from "../tool-categories";
28
+ import { useToolPresentation, registerToolPresenter } from "../tool-presenter";
29
+ import { ToolCallItem } from "../ToolCallItem";
30
+ import { ApprovalCardHeader } from "../ApprovalCard";
31
+
32
+ afterEach(cleanup);
33
+
34
+ const here = dirname(fileURLToPath(import.meta.url));
35
+ const fixturePath = resolve(here, "../../../../../test/fixtures/tool-view/intent-title.json");
36
+
37
+ interface IntentFixture {
38
+ argField: string;
39
+ cases: Array<{
40
+ name: string;
41
+ mcpServerSlug: string;
42
+ args: Record<string, unknown>;
43
+ intent: string | null;
44
+ }>;
45
+ }
46
+
47
+ const fixture = JSON.parse(readFileSync(fixturePath, "utf8")) as IntentFixture;
48
+
49
+ function makeToolCall(opts: {
50
+ name: string;
51
+ args?: Record<string, unknown>;
52
+ mcpServerSlug?: string;
53
+ status?: ToolCallStatus;
54
+ }): ToolCall {
55
+ return create(ToolCallSchema, {
56
+ id: opts.name,
57
+ name: opts.name,
58
+ args: (opts.args ?? {}) as JsonObject,
59
+ mcpServerSlug: opts.mcpServerSlug ?? "",
60
+ status: opts.status ?? ToolCallStatus.TOOL_CALL_COMPLETED,
61
+ });
62
+ }
63
+
64
+ describe("intent-title fixture contract", () => {
65
+ it("pins the wire key shared with the runner middleware", () => {
66
+ expect(SHELL_INTENT_ARG_FIELD).toBe(fixture.argField);
67
+ });
68
+
69
+ it.each(fixture.cases.map((c, i) => [i, c] as const))(
70
+ "case %#: %j",
71
+ (_i, c) => {
72
+ const tc = makeToolCall({
73
+ name: c.name,
74
+ args: c.args,
75
+ mcpServerSlug: c.mcpServerSlug,
76
+ });
77
+ expect(extractShellIntent(tc)).toBe(c.intent);
78
+ },
79
+ );
80
+ });
81
+
82
+ describe("extractShellIntentFromPreview", () => {
83
+ it("mirrors the ToolCall extraction on a JSON args preview", () => {
84
+ expect(
85
+ extractShellIntentFromPreview(
86
+ "execute",
87
+ JSON.stringify({ command: "make check", [SHELL_INTENT_ARG_FIELD]: "Verify the build" }),
88
+ ),
89
+ ).toBe("Verify the build");
90
+ });
91
+
92
+ it("prefers the denormalized wire kind over the name", () => {
93
+ // A shell tool whose NAME the classifier does not recognize still
94
+ // resolves via the wire kind — matching extractPrimaryArgFromPreview.
95
+ expect(
96
+ extractShellIntentFromPreview(
97
+ "custom_runner",
98
+ JSON.stringify({ command: "ls", [SHELL_INTENT_ARG_FIELD]: "List files" }),
99
+ "",
100
+ ToolKind.SHELL,
101
+ ),
102
+ ).toBe("List files");
103
+ });
104
+
105
+ it("returns null for malformed previews and non-shell kinds", () => {
106
+ expect(extractShellIntentFromPreview("execute", "not json{")).toBeNull();
107
+ expect(extractShellIntentFromPreview("execute", "")).toBeNull();
108
+ expect(
109
+ extractShellIntentFromPreview(
110
+ "read_file",
111
+ JSON.stringify({ file_path: "a", [SHELL_INTENT_ARG_FIELD]: "x" }),
112
+ ),
113
+ ).toBeNull();
114
+ });
115
+ });
116
+
117
+ describe("useToolPresentation title precedence", () => {
118
+ it("titles a shell row with the model-authored intent", () => {
119
+ const tc = makeToolCall({
120
+ name: "Shell",
121
+ args: { command: "npm test", [SHELL_INTENT_ARG_FIELD]: "Run the test suite" },
122
+ });
123
+ const { result } = renderHook(() => useToolPresentation(tc));
124
+
125
+ expect(result.current.intent).toBe("Run the test suite");
126
+ expect(result.current.title).toBe("Run the test suite");
127
+ // The short kind label is unchanged — chips and badges keep "Shell".
128
+ expect(result.current.label).toBe("Shell");
129
+ });
130
+
131
+ it("falls back to the category label when no intent is present", () => {
132
+ const tc = makeToolCall({ name: "Shell", args: { command: "ls" } });
133
+ const { result } = renderHook(() => useToolPresentation(tc));
134
+
135
+ expect(result.current.intent).toBeNull();
136
+ expect(result.current.title).toBe("Shell");
137
+ });
138
+
139
+ it("a registered presenter label override outranks the intent", () => {
140
+ const dispose = registerToolPresenter(ToolKind.SHELL, {
141
+ label: () => "Run command",
142
+ });
143
+ try {
144
+ const tc = makeToolCall({
145
+ name: "Shell",
146
+ args: { command: "ls", [SHELL_INTENT_ARG_FIELD]: "List files" },
147
+ });
148
+ const { result } = renderHook(() => useToolPresentation(tc));
149
+
150
+ expect(result.current.title).toBe("Run command");
151
+ expect(result.current.intent).toBe("List files");
152
+ } finally {
153
+ dispose();
154
+ }
155
+ });
156
+ });
157
+
158
+ describe("ToolCallItem shell header", () => {
159
+ function headerOf(container: HTMLElement): Element {
160
+ const row = container.querySelector('[data-cursor-target="tool-call-row"]')!;
161
+ return row.firstElementChild!;
162
+ }
163
+
164
+ it("renders the intent as the title and the command as the subtitle", () => {
165
+ const tc = makeToolCall({
166
+ name: "Shell",
167
+ args: {
168
+ command: "npx vitest run src/parser",
169
+ [SHELL_INTENT_ARG_FIELD]: "Run unit tests for the parser",
170
+ },
171
+ });
172
+ const { container } = render(<ToolCallItem toolCall={tc} />);
173
+
174
+ const header = headerOf(container);
175
+ expect(header.textContent).toContain("Run unit tests for the parser");
176
+ expect(header.textContent).toContain("npx vitest run src/parser");
177
+ expect(header.textContent).not.toContain("Shell");
178
+ });
179
+
180
+ it("renders an intent-less shell row exactly as before: bare label, no subtitle", () => {
181
+ const tc = makeToolCall({
182
+ name: "Shell",
183
+ args: { command: "ls -la" },
184
+ });
185
+ const { container } = render(<ToolCallItem toolCall={tc} />);
186
+
187
+ const header = headerOf(container);
188
+ expect(header.textContent).toContain("Shell");
189
+ // The command lives in the terminal body only — never in the header.
190
+ expect(header.textContent).not.toContain("ls -la");
191
+ });
192
+ });
193
+
194
+ describe("ApprovalCardHeader shell title", () => {
195
+ it("titles a gated shell call with the intent phrase", () => {
196
+ const pending = create(PendingApprovalSchema, {
197
+ toolCallId: "tc-1",
198
+ toolName: "execute",
199
+ toolKind: ToolKind.SHELL,
200
+ argsPreview: JSON.stringify({
201
+ command: "rm -rf build",
202
+ [SHELL_INTENT_ARG_FIELD]: "Clean the build directory",
203
+ }),
204
+ });
205
+ render(<ApprovalCardHeader pendingApproval={pending} />);
206
+
207
+ expect(screen.getByText("Clean the build directory")).toBeDefined();
208
+ });
209
+
210
+ it("keeps the bare category label when no intent is present", () => {
211
+ const pending = create(PendingApprovalSchema, {
212
+ toolCallId: "tc-2",
213
+ toolName: "execute",
214
+ toolKind: ToolKind.SHELL,
215
+ argsPreview: JSON.stringify({ command: "rm -rf build" }),
216
+ });
217
+ render(<ApprovalCardHeader pendingApproval={pending} />);
218
+
219
+ expect(screen.getByText("Shell")).toBeDefined();
220
+ });
221
+ });
@@ -340,6 +340,9 @@ export {
340
340
  toolKindToCategoryInfo,
341
341
  extractPrimaryArg,
342
342
  extractPrimaryArgFromPreview,
343
+ extractShellIntent,
344
+ extractShellIntentFromPreview,
345
+ SHELL_INTENT_ARG_FIELD,
343
346
  humanizeToolName,
344
347
  defaultDisclosureForCategory,
345
348
  isRunGroupableCategory,
@@ -192,6 +192,14 @@ export interface ToolCategoryInfo {
192
192
  readonly primaryArgField: string;
193
193
  /** Fallback argument keys tried when the primary key is absent. */
194
194
  readonly fallbackArgFields: readonly string[];
195
+ /**
196
+ * JSON argument key carrying a model-authored intent phrase for this kind
197
+ * (stigmer#276), or `undefined` for kinds without one. Shell is the only
198
+ * carrier today: both harnesses converge on `description` — the Cursor
199
+ * harness's built-in Shell tool ships it natively, and the native harness's
200
+ * tool-intent middleware adds it to the `execute` schema at bind time.
201
+ */
202
+ readonly intentArgField?: string;
195
203
  }
196
204
 
197
205
  interface KindDisplayEntry {
@@ -199,6 +207,7 @@ interface KindDisplayEntry {
199
207
  readonly label: string;
200
208
  readonly primaryField: string;
201
209
  readonly fallbackFields?: readonly string[];
210
+ readonly intentField?: string;
202
211
  }
203
212
 
204
213
  // Presentation metadata per ToolKind. Classification (name -> kind) is owned by
@@ -209,7 +218,7 @@ const KIND_DISPLAY: Partial<Record<ToolKind, KindDisplayEntry>> = {
209
218
  [ToolKind.FILE_WRITE]: { category: "write", label: "Write", primaryField: "path", fallbackFields: ["file_path", "file", "filename"] },
210
219
  [ToolKind.FILE_EDIT]: { category: "edit", label: "Edit", primaryField: "path", fallbackFields: ["file_path", "file", "filename"] },
211
220
  [ToolKind.FILE_DELETE]: { category: "delete", label: "Delete", primaryField: "path", fallbackFields: ["file_path", "file", "filename"] },
212
- [ToolKind.SHELL]: { category: "shell", label: "Shell", primaryField: "command" },
221
+ [ToolKind.SHELL]: { category: "shell", label: "Shell", primaryField: "command", intentField: "description" },
213
222
  [ToolKind.SEARCH]: { category: "search", label: "Search", primaryField: "pattern", fallbackFields: ["query", "q"] },
214
223
  [ToolKind.LIST]: { category: "list", label: "List", primaryField: "path" },
215
224
  [ToolKind.FETCH]: { category: "fetch", label: "Fetch", primaryField: "url", fallbackFields: ["uri"] },
@@ -264,6 +273,7 @@ export function toolKindToCategoryInfo(
264
273
  label: entry.label,
265
274
  primaryArgField: entry.primaryField,
266
275
  fallbackArgFields: entry.fallbackFields ?? [],
276
+ ...(entry.intentField ? { intentArgField: entry.intentField } : {}),
267
277
  };
268
278
  }
269
279
 
@@ -440,6 +450,118 @@ export function extractPrimaryArgFromPreview(
440
450
  }
441
451
  }
442
452
 
453
+ /**
454
+ * Extracts the model-authored intent phrase from a tool call's args
455
+ * (stigmer#276), or `null` when the kind carries none / the model omitted it.
456
+ *
457
+ * The intent is presentation metadata authored by the model *for the user* —
458
+ * "Run unit tests for the parser" — rendered as the row title in place of the
459
+ * bare category label. Extraction is kind-scoped via
460
+ * {@link ToolCategoryInfo.intentArgField} (shell-only today) so a same-named
461
+ * REAL argument on another kind (e.g. the sub-agent task tool's
462
+ * `description`, which is its subject) is never misread as an intent.
463
+ */
464
+ export function extractIntent(toolCall: ToolCall): string | null {
465
+ const info = resolveToolCategoryFromCall(toolCall);
466
+ if (!info.intentArgField || !toolCall.args) return null;
467
+ const value = toolCall.args[info.intentArgField];
468
+ return typeof value === "string" && value.length > 0 ? value : null;
469
+ }
470
+
471
+ /**
472
+ * {@link extractIntent} for surfaces that carry a JSON `argsPreview` string
473
+ * instead of a full {@link ToolCall} — notably `PendingApproval`. Same
474
+ * kind-scoping; returns `null` on parse failure or when absent.
475
+ */
476
+ export function extractIntentFromPreview(
477
+ toolName: string,
478
+ argsPreview: string,
479
+ mcpServerSlug?: string,
480
+ toolKind?: ToolKind,
481
+ ): string | null {
482
+ if (!argsPreview) return null;
483
+
484
+ const info =
485
+ toolKind !== undefined
486
+ ? resolveToolCategoryFromKind(toolKind, toolName, mcpServerSlug)
487
+ : resolveToolCategory(toolName, mcpServerSlug);
488
+ if (!info.intentArgField) return null;
489
+
490
+ try {
491
+ const parsed = JSON.parse(argsPreview);
492
+ if (typeof parsed !== "object" || parsed === null) return null;
493
+ const value = (parsed as Record<string, unknown>)[info.intentArgField];
494
+ return typeof value === "string" && value.length > 0 ? value : null;
495
+ } catch {
496
+ return null;
497
+ }
498
+ }
499
+
500
+ /**
501
+ * The tool-call argument that carries a model-authored intent phrase for
502
+ * SHELL tools (stigmer#276) — a short human description of what the command
503
+ * does and why, rendered as the row title with the command as secondary text.
504
+ *
505
+ * Two writers populate it, converged on one wire key by design: the native
506
+ * harness's tool-intent middleware (`backend/services/runner/src/middleware/
507
+ * tool-intent.ts`, `INTENT_ARG`) extends the shell tool's bind-time schema
508
+ * with it, and the Cursor harness's built-in Shell tool carries it natively.
509
+ * The shared, machine-checked contract is
510
+ * `test/fixtures/tool-view/intent-title.json` — keep all three in lockstep.
511
+ */
512
+ export const SHELL_INTENT_ARG_FIELD = "description";
513
+
514
+ /**
515
+ * The intent-title extraction core: SHELL-kind-scoped on purpose, because
516
+ * `description` means other things on other tools (a task tool's description
517
+ * is the sub-agent subject, never a row title). Blank and non-string values
518
+ * degrade to null — the caller falls back to the category label.
519
+ */
520
+ function extractIntentFromArgs(kind: ToolKind, args: JsonObject | undefined): string | null {
521
+ if (kind !== ToolKind.SHELL || !args) return null;
522
+ const value = args[SHELL_INTENT_ARG_FIELD];
523
+ if (typeof value !== "string") return null;
524
+ const trimmed = value.trim();
525
+ return trimmed.length > 0 ? trimmed : null;
526
+ }
527
+
528
+ /**
529
+ * Extracts the model-authored intent phrase from a SHELL tool call, or null
530
+ * when absent (legacy executions, models that skipped the optional arg, and
531
+ * every non-shell kind). See {@link SHELL_INTENT_ARG_FIELD}.
532
+ */
533
+ export function extractShellIntent(toolCall: ToolCall): string | null {
534
+ return extractIntentFromArgs(resolveToolKind(toolCall), toolCall.args);
535
+ }
536
+
537
+ /**
538
+ * Preview-string twin of {@link extractShellIntent} for surfaces that carry a
539
+ * `PendingApproval` (JSON `argsPreview`) instead of a full {@link ToolCall} —
540
+ * mirrors {@link extractPrimaryArgFromPreview}'s kind resolution.
541
+ */
542
+ export function extractShellIntentFromPreview(
543
+ toolName: string,
544
+ argsPreview: string,
545
+ mcpServerSlug?: string,
546
+ toolKind?: ToolKind,
547
+ ): string | null {
548
+ if (!argsPreview) return null;
549
+
550
+ const kind =
551
+ toolKind !== undefined && toolKind !== ToolKind.UNSPECIFIED
552
+ ? toolKind
553
+ : resolveToolKindByName(toolName, mcpServerSlug);
554
+ if (kind !== ToolKind.SHELL) return null;
555
+
556
+ try {
557
+ const parsed = JSON.parse(argsPreview);
558
+ if (typeof parsed !== "object" || parsed === null) return null;
559
+ return extractIntentFromArgs(kind, parsed as JsonObject);
560
+ } catch {
561
+ return null;
562
+ }
563
+ }
564
+
443
565
  const WRITE_CONTENT_FIELDS = [
444
566
  "contents",
445
567
  "content",
@@ -19,6 +19,7 @@ import type { ToolResultView } from "@stigmer/sdk";
19
19
  import {
20
20
  resolveToolCategoryFromCall,
21
21
  extractPrimaryArg,
22
+ extractShellIntent,
22
23
  defaultChromeForCategory,
23
24
  defaultDisclosureForCategory,
24
25
  isRunGroupableCategory,
@@ -124,6 +125,21 @@ export interface ToolPresentation {
124
125
  readonly category: ToolCategory;
125
126
  /** Display label, e.g. "Edit", "Shell". */
126
127
  readonly label: string;
128
+ /**
129
+ * The model-authored intent phrase for a SHELL call (stigmer#276), or null
130
+ * when absent (legacy executions, skipped optional arg, non-shell kinds).
131
+ * Raw extraction — precedence against overrides is resolved in
132
+ * {@link ToolPresentation.title}.
133
+ */
134
+ readonly intent: string | null;
135
+ /**
136
+ * The row title: a registered presenter's `label` override wins (a host's
137
+ * explicit wording is never displaced by model output), then the
138
+ * model-authored {@link intent}, then the category {@link label}. Renderers
139
+ * that title a row use this; {@link label} stays the short kind label for
140
+ * chips and badges.
141
+ */
142
+ readonly title: string;
127
143
  /** Primary argument (path, command, pattern...), or null. */
128
144
  readonly primaryArg: string | null;
129
145
  /** Normalized result view for rendering with {@link ResultView}. */
@@ -166,7 +182,10 @@ export function useToolPresentation(toolCall: ToolCall): ToolPresentation {
166
182
  const result = normalizeToolResult(toolCall);
167
183
  const override = registry.get(kind);
168
184
 
169
- const label = override?.label?.(toolCall) ?? categoryInfo.label;
185
+ const overrideLabel = override?.label?.(toolCall);
186
+ const label = overrideLabel ?? categoryInfo.label;
187
+ const intent = extractShellIntent(toolCall);
188
+ const title = overrideLabel ?? intent ?? categoryInfo.label;
170
189
  const resultSummary =
171
190
  override?.summary?.(toolCall, result) ?? summarizeResultView(result);
172
191
  const disclosure =
@@ -183,6 +202,8 @@ export function useToolPresentation(toolCall: ToolCall): ToolPresentation {
183
202
  kind,
184
203
  category: categoryInfo.category,
185
204
  label,
205
+ intent,
206
+ title,
186
207
  primaryArg: extractPrimaryArg(toolCall),
187
208
  result,
188
209
  resultSummary,
package/src/index.ts CHANGED
@@ -296,6 +296,9 @@ export {
296
296
  toolKindToCategoryInfo,
297
297
  defaultDisclosureForCategory,
298
298
  extractPrimaryArg,
299
+ extractShellIntent,
300
+ extractShellIntentFromPreview,
301
+ SHELL_INTENT_ARG_FIELD,
299
302
  McpToolDetail,
300
303
  parseMcpResult,
301
304
  formatDuration,