@adecore/agents-react 0.25.8 → 0.25.9

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 (39) hide show
  1. package/dist/chat/index.d.ts +1 -0
  2. package/dist/chat/index.js +1 -0
  3. package/dist/chat/logic/command-looks.d.ts +19 -0
  4. package/dist/chat/logic/command-looks.js +123 -0
  5. package/dist/chat/logic/embedded-code.d.ts +25 -0
  6. package/dist/chat/logic/embedded-code.js +27 -5
  7. package/dist/chat/logic/timeline.d.ts +5 -3
  8. package/dist/chat/logic/timeline.js +64 -10
  9. package/dist/chat/ui/ShellCommand.d.ts +11 -1
  10. package/dist/chat/ui/ShellCommand.js +60 -20
  11. package/dist/chat/ui/SubagentTimeline.js +9 -1
  12. package/dist/chat/ui/Timeline.js +9 -2
  13. package/dist/chat/ui/rows/Rows.js +2 -2
  14. package/dist/chat/ui/rows/SubagentRow.js +1 -1
  15. package/dist/chat/ui/rows/WorkRows.d.ts +13 -4
  16. package/dist/chat/ui/rows/WorkRows.js +55 -11
  17. package/dist/chat/ui/rows/WorkflowRow.js +1 -1
  18. package/dist/host.d.ts +25 -0
  19. package/dist/index.d.ts +1 -1
  20. package/dist/locales/en/agent-chat.d.ts +4 -0
  21. package/dist/locales/en/agent-chat.js +5 -1
  22. package/dist/locales/nl/agent-chat.d.ts +4 -0
  23. package/dist/locales/nl/agent-chat.js +5 -1
  24. package/package.json +8 -8
  25. package/src/chat/index.ts +1 -0
  26. package/src/chat/logic/command-looks.ts +157 -0
  27. package/src/chat/logic/embedded-code.ts +57 -11
  28. package/src/chat/logic/timeline.ts +70 -13
  29. package/src/chat/ui/ShellCommand.tsx +83 -23
  30. package/src/chat/ui/SubagentTimeline.tsx +11 -2
  31. package/src/chat/ui/Timeline.tsx +9 -2
  32. package/src/chat/ui/rows/Rows.tsx +2 -2
  33. package/src/chat/ui/rows/SubagentRow.tsx +1 -1
  34. package/src/chat/ui/rows/WorkRows.tsx +136 -19
  35. package/src/chat/ui/rows/WorkflowRow.tsx +1 -1
  36. package/src/host.ts +43 -0
  37. package/src/index.ts +3 -0
  38. package/src/locales/en/agent-chat.ts +5 -1
  39. package/src/locales/nl/agent-chat.ts +5 -1
@@ -29,7 +29,7 @@ function ChildRow({ chatId, item }) {
29
29
  if (isImageGeneration(item)) {
30
30
  return _jsx(GeneratedImageRow, { chatId: chatId, tool: item });
31
31
  }
32
- return item.state === 'running' ? _jsx(WorkLiveRow, { chatId: chatId, tool: item }) : _jsx(WorkRow, { tool: item });
32
+ return item.state === 'running' ? _jsx(WorkLiveRow, { chatId: chatId, tool: item }) : _jsx(WorkRow, { chatId: chatId, tool: item });
33
33
  }
34
34
  if (item.kind === 'assistant') {
35
35
  return _jsx(ChildText, { chatId: chatId, item: item });
@@ -1,11 +1,18 @@
1
1
  import { type ReactNode } from 'react';
2
2
  import type { ChatCheckpointDiff, ChatToolItem, ChatTurnItem } from '@adecore/agent-contracts';
3
3
  import type { BaseMoved } from '../../logic/timeline.js';
4
- export declare function ToggleLine({ icon, label, detail, command, path, open, onToggle, failed, live, trailing, time, className }: {
4
+ import { type CommandLookRun } from '../../logic/command-looks.js';
5
+ import { type CommandLook } from '../../../host.js';
6
+ export declare function ToggleLine({ icon, label, brand, detail, command, look, path, open, onToggle, failed, live, trailing, time, className }: {
5
7
  icon: ReactNode;
6
- label: string;
8
+ label: ReactNode;
9
+ brand?: string;
7
10
  detail?: string;
8
11
  command?: boolean;
12
+ look?: {
13
+ run: CommandLookRun;
14
+ chatId: string;
15
+ };
9
16
  path?: boolean;
10
17
  open?: boolean;
11
18
  onToggle?(): void;
@@ -15,8 +22,9 @@ export declare function ToggleLine({ icon, label, detail, command, path, open, o
15
22
  time?: number;
16
23
  className?: string;
17
24
  }): import("react").JSX.Element;
18
- export declare function WorkRow({ tool, nested, detail, icon, label, failed }: {
25
+ export declare function WorkRow({ tool, chatId, nested, detail, icon, label, failed }: {
19
26
  tool: ChatToolItem;
27
+ chatId?: string;
20
28
  nested?: boolean;
21
29
  detail?: string;
22
30
  icon?: ReactNode;
@@ -30,9 +38,10 @@ export declare function WorkLiveRow({ chatId, tool: derived }: {
30
38
  chatId: string;
31
39
  tool: ChatToolItem;
32
40
  }): import("react").JSX.Element;
33
- export declare function WorkGroupRow({ tools, summary, expanded, onToggle }: {
41
+ export declare function WorkGroupRow({ tools, summary, look, expanded, onToggle }: {
34
42
  tools: ChatToolItem[];
35
43
  summary: string;
44
+ look?: CommandLook;
36
45
  expanded: boolean;
37
46
  onToggle(): void;
38
47
  }): import("react").JSX.Element;
@@ -1,15 +1,16 @@
1
1
  import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { Fragment, useEffect, useState } from 'react';
2
+ import { Fragment, useContext, useEffect, useState } from 'react';
3
3
  import clsx from 'clsx';
4
4
  import i18next from 'i18next';
5
5
  import { useTranslation } from 'react-i18next';
6
6
  import { ChevronRight, FileDiff, X } from 'lucide-react';
7
+ import { matchCommandLook } from '../../logic/command-looks.js';
7
8
  import { exitCodeOf, fileChanges, hasOutput, hasToolBody, liveOutput, readImagePath, shellCommand, toolCallLines, toolStartedAt, toolSummary, toolSummaryIsPath, unifiedChanges } from '../../logic/tools.js';
8
9
  import { useOpenForFind } from '../find-reveal.js';
9
10
  import { useChatHost } from '../../../host.js';
10
11
  import { lazyNamed } from '../../../lazy.js';
11
12
  import { useChatActions } from '../../actions.js';
12
- import { useChatScope } from '../../../scope.js';
13
+ import { ChatScopeContext, useChatScope } from '../../../scope.js';
13
14
  import { useCurrentItem } from '../../../state/chats.js';
14
15
  import { useTimelineFlash } from '../../timeline-flash.js';
15
16
  import { formatClockDuration, formatElapsedShort } from '@adecore/ui/format';
@@ -31,15 +32,51 @@ function clipNotice(text) {
31
32
  function clip(text) {
32
33
  return text.slice(0, OUTPUT_LIMIT) + clipNotice(text);
33
34
  }
35
+ const BRAND = 'font-semibold';
36
+ /* `text` with `brand` in semibold where it names it, as a sentence about a look does. */
37
+ function withBrand(text, brands) {
38
+ for (const brand of brands) {
39
+ const at = brand === '' ? -1 : text.indexOf(brand);
40
+ if (at !== -1) {
41
+ return (_jsxs(_Fragment, { children: [text.slice(0, at), _jsx("span", { className: BRAND, children: brand }), text.slice(at + brand.length)] }));
42
+ }
43
+ }
44
+ return text;
45
+ }
46
+ /* The detail of a look's run, with the words its host names drawn as those names. */
47
+ function NamedLookDetail({ run, chatId, useNames }) {
48
+ const scope = useContext(ChatScopeContext);
49
+ const nameOf = useNames(scope?.id ?? '', chatId);
50
+ const names = run.words.flatMap((word) => {
51
+ const name = nameOf(word.text, word.option);
52
+ return name === null ? [] : [{ start: word.start, end: word.end, word: word.text, name }];
53
+ });
54
+ return _jsx(ShellCommand, { command: run.detail, code: run.code, names: names });
55
+ }
56
+ function LookDetail({ run, chatId }) {
57
+ const { useNames } = run.look;
58
+ if (useNames === undefined) {
59
+ return _jsx(ShellCommand, { command: run.detail, code: run.code });
60
+ }
61
+ // Keyed by the look, so a row whose command turns into another look's starts its hooks again.
62
+ return _jsx(NamedLookDetail, { run: run, chatId: chatId, useNames: useNames }, run.look.program);
63
+ }
64
+ const TAG = 'shrink-0 rounded bg-surface-hover px-1.5 text-text-faint';
65
+ /* What else the line ran, in quiet tags: the other programs and a heredoc, and how many more runs of a look. */
66
+ function LookTags({ run }) {
67
+ const { t } = useTranslation('agent-chat');
68
+ const also = run.heredoc ? [...run.others, t('work.heredoc')] : run.others;
69
+ return (_jsxs(_Fragment, { children: [also.length > 0 && _jsx("span", { className: TAG, children: t('work.alsoRan', { programs: also.join(', ') }) }), run.more > 0 && _jsx("span", { className: TAG, children: t('work.moreRuns', { count: run.more }) })] }));
70
+ }
34
71
  /*
35
72
  * The line of a call, a subagent or a group. Without `onToggle` it has nothing to open: no chevron, and
36
73
  * not a button. `time` shows at the right edge while the line is hovered or focused.
37
74
  */
38
- export function ToggleLine({ icon, label, detail, command, path, open = false, onToggle, failed, live, trailing, time, className }) {
75
+ export function ToggleLine({ icon, label, brand, detail, command, look, path, open = false, onToggle, failed, live, trailing, time, className }) {
39
76
  const classes = clsx(
40
77
  // Fix the width so long commands reach the truncation inside the negative margins.
41
78
  'group/line -mx-1 mb-0.5 flex h-7 w-[calc(100%+8px)] items-center gap-2 rounded-md px-1 text-left text-xs text-text-muted', onToggle !== undefined && 'hover:bg-surface-hover', failed && 'text-status-error', className);
42
- const content = (_jsxs(_Fragment, { children: [_jsx("span", { className: clsx(ROW_GUTTER, live && 'text-accent'), children: icon }), _jsx("span", { className: clsx('shrink-0', live && 'chat-live-text'), children: label }), detail && (_jsx("span", { "data-find-field": "summary", className: "min-w-0 truncate font-mono text-text-faint", children: command ? _jsx(ShellCommand, { command: detail }) : path ? _jsx(MiddleTruncate, { text: detail }) : detail })), _jsx("span", { className: "grow" }), trailing, time !== undefined && _jsx(RowTime, { at: time }), failed && _jsx(Icon, { icon: X, size: 12, className: "shrink-0" }), onToggle !== undefined && (_jsx(Icon, { icon: ChevronRight, size: 12, className: clsx('shrink-0 text-text-faint transition-transform', open && 'rotate-90') }))] }));
79
+ const content = (_jsxs(_Fragment, { children: [_jsx("span", { className: clsx(ROW_GUTTER, live && 'text-accent'), children: icon }), _jsxs("span", { className: clsx('shrink-0', live && 'chat-live-text'), children: [brand !== undefined && _jsx("span", { className: BRAND, children: brand }), brand !== undefined && label !== '' && ' ', label] }), look !== undefined ? (_jsxs(_Fragment, { children: [look.run.detail !== '' && (_jsx("span", { "data-find-field": "summary", className: "min-w-0 truncate font-mono text-text-faint", children: _jsx(LookDetail, { run: look.run, chatId: look.chatId }) })), _jsx(LookTags, { run: look.run })] })) : (detail && (_jsx("span", { "data-find-field": "summary", className: "min-w-0 truncate font-mono text-text-faint", children: command ? _jsx(ShellCommand, { command: detail }) : path ? _jsx(MiddleTruncate, { text: detail }) : detail }))), _jsx("span", { className: "grow" }), trailing, time !== undefined && _jsx(RowTime, { at: time }), failed && _jsx(Icon, { icon: X, size: 12, className: "shrink-0" }), onToggle !== undefined && (_jsx(Icon, { icon: ChevronRight, size: 12, className: clsx('shrink-0 text-text-faint transition-transform', open && 'rotate-90') }))] }));
43
80
  if (onToggle === undefined) {
44
81
  return _jsx("div", { className: classes, children: content });
45
82
  }
@@ -82,7 +119,7 @@ function ToolBody({ tool }) {
82
119
  * One settled tool call: a line, and what it was asked and answered behind it. An image the call
83
120
  * looked at is drawn under the line. `icon`, `label` and `failed` let a row that knows the call better name it.
84
121
  */
85
- export function WorkRow({ tool, nested, detail, icon, label, failed }) {
122
+ export function WorkRow({ tool, chatId, nested, detail, icon, label, failed }) {
86
123
  const host = useChatHost();
87
124
  const [open, setOpen] = useState(false);
88
125
  const image = tool.state === 'done' ? readImagePath(tool.name, tool.input) : null;
@@ -90,7 +127,9 @@ export function WorkRow({ tool, nested, detail, icon, label, failed }) {
90
127
  useOpenForFind(tool.id, 'output', setOpen);
91
128
  const summary = detail ?? toolSummary(tool.name, tool.input);
92
129
  const opens = hasToolBody(tool);
93
- return (_jsxs("div", { "data-find-item": tool.id, className: nested ? 'ml-6' : undefined, children: [_jsx(ToggleLine, { icon: icon ?? toolIcon(tool.name), label: label ?? tool.name, detail: summary, command: detail === undefined && summary === shellCommand(tool.name, tool.input), path: detail === undefined && toolSummaryIsPath(tool.name, tool.input), open: open && opens, onToggle: opens ? () => setOpen((o) => !o) : undefined, failed: failed ?? tool.state === 'error', time: tool.createdAt }), image !== null && ReadImage !== null && _jsx(ReadImage, { path: image }), open && opens && _jsx(ToolBody, { tool: tool })] }));
130
+ const shell = shellCommand(tool.name, tool.input);
131
+ const run = detail === undefined && icon === undefined && label === undefined && shell !== null ? matchCommandLook(shell, host.commandLooks) : null;
132
+ return (_jsxs("div", { "data-find-item": tool.id, className: nested ? 'ml-6' : undefined, children: [_jsx(ToggleLine, { icon: run === null ? (icon ?? toolIcon(tool.name)) : _jsx(run.look.Mark, { size: 12 }), label: run === null ? (label ?? tool.name) : run.match.label, brand: run?.look.brand, detail: summary, look: run === null ? undefined : { run, chatId: chatId ?? '' }, command: detail === undefined && summary === shell, path: detail === undefined && toolSummaryIsPath(tool.name, tool.input), open: open && opens, onToggle: opens ? () => setOpen((o) => !o) : undefined, failed: failed ?? tool.state === 'error', time: tool.createdAt }), image !== null && ReadImage !== null && _jsx(ReadImage, { path: image }), open && opens && _jsx(ToolBody, { tool: tool })] }));
94
133
  }
95
134
  /* "running for 12s" next to a live call. */
96
135
  export function RunningFor({ startedAt }) {
@@ -105,19 +144,24 @@ export function WorkLiveRow({ chatId, tool: derived }) {
105
144
  const tail = liveOutput(tool);
106
145
  const summary = toolSummary(tool.name, tool.input);
107
146
  const opens = hasToolBody(tool);
108
- return (_jsxs("div", { children: [_jsx(ToggleLine, { icon: toolIcon(tool.name), label: tool.name, detail: summary || tool.progress?.description || '', command: summary !== '' && summary === shellCommand(tool.name, tool.input), path: summary !== '' && toolSummaryIsPath(tool.name, tool.input), open: open && opens, onToggle: opens ? () => setOpen((o) => !o) : undefined, trailing: _jsx(RunningFor, { startedAt: toolStartedAt(tool) }), live: true }), open && opens && _jsx(ToolBody, { tool: tool }), tail !== null && !open && (_jsx("pre", { className: "mt-1.5 mb-2 ml-6 max-h-48 overflow-auto rounded-md border border-border bg-surface-raised px-3 py-2 font-mono text-code whitespace-pre-wrap text-chat-output select-text", children: _jsx(AnsiOutput, { text: tail, tailLines: 12 }) }))] }));
147
+ const shell = shellCommand(tool.name, tool.input);
148
+ const { commandLooks } = useChatHost();
149
+ const run = shell === null ? null : matchCommandLook(shell, commandLooks);
150
+ return (_jsxs("div", { children: [_jsx(ToggleLine, { icon: run === null ? toolIcon(tool.name) : _jsx(run.look.Mark, { size: 12 }), label: run === null ? tool.name : run.match.label, brand: run?.look.brand, look: run === null ? undefined : { run, chatId }, detail: summary || tool.progress?.description || '', command: summary !== '' && summary === shellCommand(tool.name, tool.input), path: summary !== '' && toolSummaryIsPath(tool.name, tool.input), open: open && opens, onToggle: opens ? () => setOpen((o) => !o) : undefined, trailing: _jsx(RunningFor, { startedAt: toolStartedAt(tool) }), live: true }), open && opens && _jsx(ToolBody, { tool: tool }), tail !== null && !open && (_jsx("pre", { className: "mt-1.5 mb-2 ml-6 max-h-48 overflow-auto rounded-md border border-border bg-surface-raised px-3 py-2 font-mono text-code whitespace-pre-wrap text-chat-output select-text", children: _jsx(AnsiOutput, { text: tail, tailLines: 12 }) }))] }));
109
151
  }
110
- /* A run of tool calls as one line ("Read 4 files"); opening it lists every call. */
111
- export function WorkGroupRow({ tools, summary, expanded, onToggle }) {
152
+ /* A run of tool calls as one line ("Read 4 files"); opening it lists every call. A run of one look wears its mark and its brand. */
153
+ export function WorkGroupRow({ tools, summary, look, expanded, onToggle }) {
112
154
  const failed = tools.some((tool) => tool.state === 'error');
113
- return (_jsx("div", { children: _jsx(ToggleLine, { icon: toolIcon(tools[0].name), label: summary, open: expanded, onToggle: onToggle, failed: failed, time: tools[0].createdAt }) }));
155
+ return (_jsx("div", { children: _jsx(ToggleLine, { icon: look === undefined ? toolIcon(tools[0].name) : _jsx(look.Mark, { size: 12 }), label: look?.brand === undefined ? summary : withBrand(summary, [look.brand]), open: expanded, onToggle: onToggle, failed: failed, time: tools[0].createdAt }) }));
114
156
  }
115
157
  /*
116
158
  * A settled turn folded behind how long it took and what it did. A line and not a button, like the
117
159
  * tool lines it hides; the work reads fainter than the time so the eye lands on the time first.
118
160
  */
119
161
  export function TurnFoldRow({ turn, label, work, expanded, onToggle }) {
120
- return (_jsxs("button", { className: clsx('group group/line -mx-1 mb-2 flex h-7 w-[calc(100%+8px)] min-w-0 items-center gap-2 rounded-md px-1 text-left text-xs text-text-muted hover:bg-surface-hover', turn.state === 'error' && 'text-status-error'), "aria-expanded": expanded, onClick: onToggle, children: [_jsx("span", { className: ROW_GUTTER, children: _jsx(Icon, { icon: ChevronRight, size: 12, className: clsx('transition-transform', expanded && 'rotate-90') }) }), _jsx("span", { className: "shrink-0 group-hover:text-text", children: label }), work.length > 0 && _jsx("span", { className: "min-w-0 truncate text-text-faint", children: work.map((part) => ` · ${part}`).join('') }), _jsx("span", { className: "grow" }), _jsx(RowTime, { at: turn.createdAt })] }));
162
+ const { commandLooks } = useChatHost();
163
+ const brands = commandLooks?.flatMap(({ brand }) => (brand === undefined ? [] : [brand])) ?? [];
164
+ return (_jsxs("button", { className: clsx('group group/line -mx-1 mb-2 flex h-7 w-[calc(100%+8px)] min-w-0 items-center gap-2 rounded-md px-1 text-left text-xs text-text-muted hover:bg-surface-hover', turn.state === 'error' && 'text-status-error'), "aria-expanded": expanded, onClick: onToggle, children: [_jsx("span", { className: ROW_GUTTER, children: _jsx(Icon, { icon: ChevronRight, size: 12, className: clsx('transition-transform', expanded && 'rotate-90') }) }), _jsx("span", { className: "shrink-0 group-hover:text-text", children: label }), work.length > 0 && (_jsx("span", { className: "min-w-0 truncate text-text-faint", children: work.map((part, index) => (_jsxs(Fragment, { children: [' · ', withBrand(part, brands)] }, index))) })), _jsx("span", { className: "grow" }), _jsx(RowTime, { at: turn.createdAt })] }));
121
165
  }
122
166
  function omittedLabel(reason) {
123
167
  return reason === 'binary' ? i18next.t('agent-chat:work.omitted.binary') : i18next.t('agent-chat:work.omitted.tooLarge');
@@ -45,5 +45,5 @@ export function WorkflowRow({ tool, workflow, chatId, onOpenAgent }) {
45
45
  });
46
46
  }
47
47
  };
48
- return (_jsxs("div", { children: [_jsxs("div", { className: "flex items-center gap-1", children: [_jsx("div", { className: "min-w-0 grow", children: running ? _jsx(WorkLiveRow, { chatId: chatId ?? '', tool: tool }) : _jsx(WorkRow, { tool: tool, detail: workflow.name ?? undefined }) }), running && chatId && workflow.taskId && _jsx(IconButton, { icon: Square, size: "xs", label: t('activity.stop'), onClick: stop })] }), running && workflow.stalledAt !== undefined && (_jsxs("p", { role: "status", className: "ml-6 mb-1 flex items-start gap-1.5 text-xs text-text-muted", children: [_jsx(Icon, { icon: TriangleAlert, size: 12, className: "mt-0.5 shrink-0 text-status-needs-you" }), _jsx("span", { children: t('rows.workflow.stalled') })] })), phases.length > 0 && (_jsx("div", { className: "ml-6", children: phases.map((phase) => (_jsx(PhaseBlock, { phase: phase, onOpen: onOpen }, phase.key))) }))] }));
48
+ return (_jsxs("div", { children: [_jsxs("div", { className: "flex items-center gap-1", children: [_jsx("div", { className: "min-w-0 grow", children: running ? _jsx(WorkLiveRow, { chatId: chatId ?? '', tool: tool }) : _jsx(WorkRow, { chatId: chatId, tool: tool, detail: workflow.name ?? undefined }) }), running && chatId && workflow.taskId && _jsx(IconButton, { icon: Square, size: "xs", label: t('activity.stop'), onClick: stop })] }), running && workflow.stalledAt !== undefined && (_jsxs("p", { role: "status", className: "ml-6 mb-1 flex items-start gap-1.5 text-xs text-text-muted", children: [_jsx(Icon, { icon: TriangleAlert, size: 12, className: "mt-0.5 shrink-0 text-status-needs-you" }), _jsx("span", { children: t('rows.workflow.stalled') })] })), phases.length > 0 && (_jsx("div", { className: "ml-6", children: phases.map((phase) => (_jsx(PhaseBlock, { phase: phase, onOpen: onOpen }, phase.key))) }))] }));
49
49
  }
package/dist/host.d.ts CHANGED
@@ -125,6 +125,30 @@ export interface ReplyAuthor {
125
125
  name: string;
126
126
  mark?: ReactNode;
127
127
  }
128
+ export interface CommandName {
129
+ name: string;
130
+ icon?: ReactNode;
131
+ }
132
+ export interface CommandLookMatch {
133
+ label: string;
134
+ detailFrom: number;
135
+ group?: {
136
+ key: string;
137
+ sentence(count: number): string;
138
+ };
139
+ fold?: readonly string[];
140
+ languages?: Readonly<Record<string, string>>;
141
+ }
142
+ export interface CommandLook {
143
+ program: string;
144
+ brand?: string;
145
+ Mark: ComponentType<{
146
+ size: number;
147
+ }>;
148
+ describe(args: readonly string[]): CommandLookMatch | null;
149
+ sentence?(count: number): string;
150
+ useNames?(scopeId: string, chatId: string): (word: string, option: string | null) => CommandName | null;
151
+ }
128
152
  export type UiMapSource = {
129
153
  state: 'tiles';
130
154
  styleUrl(theme: 'light' | 'dark'): string;
@@ -240,6 +264,7 @@ export interface ChatHost {
240
264
  useThreadCards(scopeId: string, chatId: string): readonly ThreadCard[];
241
265
  useReplyAuthor(scopeId: string, chatId: string): ReplyAuthor | null;
242
266
  visuals: VisualHost | null;
267
+ commandLooks?: readonly CommandLook[];
243
268
  }
244
269
  export declare function ChatHostProvider({ host, children }: {
245
270
  host: Partial<ChatHost>;
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- export { type ChatActions, type FileRef, type ShellCodeBlockContext, type ChatPlace, type SubagentTask, type TimelineFindOptions, type TimelineFind, type ComposerDictationProps, type ComposerSlotProps, type SubagentSlotProps, type ThreadCard, type VisualHost, type ReplyAuthor, type ChatHost, type UiMapSource, ChatHostProvider, useChatHost, type AccentChoice, type ChatToast, type TextareaProps, type ResourceUrl } from './host.js';
1
+ export { type ChatActions, type FileRef, type ShellCodeBlockContext, type ChatPlace, type SubagentTask, type TimelineFindOptions, type TimelineFind, type ComposerDictationProps, type ComposerSlotProps, type SubagentSlotProps, type ThreadCard, type VisualHost, type ReplyAuthor, type ChatHost, type UiMapSource, type CommandLook, type CommandLookMatch, type CommandName, ChatHostProvider, useChatHost, type AccentChoice, type ChatToast, type TextareaProps, type ResourceUrl } from './host.js';
2
2
  export { type ChatScope, ChatScopeContext, useChatScope } from './scope.js';
3
3
  export { type ChatRequestMap, type ChatEventMap, type ChatTransportStatus, type ChatTransport, ChatTransportError, errorCode, isConnectionError } from './transport.js';
4
4
  export { portTransport, type PortTransport } from './port-transport.js';
@@ -199,6 +199,10 @@ declare const _default: {
199
199
  edits_other: string;
200
200
  workingFor: string;
201
201
  exitCode: string;
202
+ alsoRan: string;
203
+ heredoc: string;
204
+ moreRuns_one: string;
205
+ moreRuns_other: string;
202
206
  };
203
207
  timeline: {
204
208
  welcome: {
@@ -198,7 +198,11 @@ export default {
198
198
  edits_one: '{{count}} edit',
199
199
  edits_other: '{{count}} edits',
200
200
  workingFor: 'Working for',
201
- exitCode: 'Exit code {{code}}'
201
+ exitCode: 'Exit code {{code}}',
202
+ alsoRan: '+ {{programs}}',
203
+ heredoc: 'heredoc',
204
+ moreRuns_one: '+{{count}}',
205
+ moreRuns_other: '+{{count}}'
202
206
  },
203
207
  timeline: {
204
208
  welcome: {
@@ -199,6 +199,10 @@ declare const _default: {
199
199
  edits_other: string;
200
200
  workingFor: string;
201
201
  exitCode: string;
202
+ alsoRan: string;
203
+ heredoc: string;
204
+ moreRuns_one: string;
205
+ moreRuns_other: string;
202
206
  };
203
207
  timeline: {
204
208
  welcome: {
@@ -198,7 +198,11 @@ export default {
198
198
  edits_one: '{{count}} bewerking',
199
199
  edits_other: '{{count}} bewerkingen',
200
200
  workingFor: 'Werkt al',
201
- exitCode: 'Afsluitcode {{code}}'
201
+ exitCode: 'Afsluitcode {{code}}',
202
+ alsoRan: '+ {{programs}}',
203
+ heredoc: 'heredoc',
204
+ moreRuns_one: '+{{count}}',
205
+ moreRuns_other: '+{{count}}'
202
206
  },
203
207
  timeline: {
204
208
  welcome: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adecore/agents-react",
3
- "version": "0.25.8",
3
+ "version": "0.25.9",
4
4
  "description": "React agent chat threads, composers, approvals, provider accounts and usage views.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -67,11 +67,11 @@
67
67
  "test:visuals": "bun --config=integration.bunfig.toml --conditions=source test ./src/chat/visual-viewport.integration.test.ts"
68
68
  },
69
69
  "dependencies": {
70
- "@adecore/agent-contracts": "0.25.8",
71
- "@adecore/editor": "0.25.8",
72
- "@adecore/editor-react": "0.25.8",
73
- "@adecore/intelligent-ui": "0.25.8",
74
- "@adecore/merge": "0.25.8",
70
+ "@adecore/agent-contracts": "0.25.9",
71
+ "@adecore/editor": "0.25.9",
72
+ "@adecore/editor-react": "0.25.9",
73
+ "@adecore/intelligent-ui": "0.25.9",
74
+ "@adecore/merge": "0.25.9",
75
75
  "@codemirror/commands": "^6.11.1",
76
76
  "@codemirror/language": "^6.12.4",
77
77
  "@codemirror/state": "^6.7.6",
@@ -94,14 +94,14 @@
94
94
  "zustand": "^5.0.15"
95
95
  },
96
96
  "peerDependencies": {
97
- "@adecore/ui": "^0.25.8",
97
+ "@adecore/ui": "^0.25.9",
98
98
  "i18next": "^26.4.2",
99
99
  "react": "^19.3.0",
100
100
  "react-dom": "^19.3.0",
101
101
  "react-i18next": "^17.0.15"
102
102
  },
103
103
  "devDependencies": {
104
- "@adecore/ui": "0.25.8",
104
+ "@adecore/ui": "0.25.9",
105
105
  "@types/bun": "^1.4.2",
106
106
  "@types/react": "^19.3.0",
107
107
  "@types/react-dom": "^19.3.0",
package/src/chat/index.ts CHANGED
@@ -21,6 +21,7 @@ export {
21
21
  type ForkPoint,
22
22
  type ForkWorktreeChoice
23
23
  } from './logic/fork';
24
+ export { matchCommandLook, type CommandLookRun, type CommandLookWord } from './logic/command-looks';
24
25
  export { handbackReportOf } from './logic/handback';
25
26
  export { limitView, type LimitView } from './logic/limit';
26
27
  export type { TickKind, ScrubberTick } from './logic/scrubber';
@@ -0,0 +1,157 @@
1
+ import type { CommandLook, CommandLookMatch } from '../../host';
2
+ import { EMBEDDED_CODE_LIMIT, shellCommands, type EmbeddedCode, type ShellSimpleCommand, type ShellWord } from './embedded-code';
3
+
4
+ // A folded value keeps this many characters of what it says.
5
+ const FOLD_LENGTH = 40;
6
+ // Rows redraw often and a thread holds many commands, so each is read once per set of looks.
7
+ const CACHE_LIMIT = 300;
8
+
9
+ /* A word of a detail a host may give a name, as offsets into the detail. */
10
+ export interface CommandLookWord {
11
+ readonly start: number;
12
+ readonly end: number;
13
+ /* The word as the program gets it, quotes taken out. */
14
+ readonly text: string;
15
+ /* The option right before it, whose value it may be; null when it follows none. */
16
+ readonly option: string | null;
17
+ }
18
+
19
+ /* A shell call read as a tool of its own: the first command of the line a look knows. */
20
+ export interface CommandLookRun {
21
+ readonly look: CommandLook;
22
+ readonly match: CommandLookMatch;
23
+ /* The source from the detail word on, as typed, without redirects and with folded values shortened. */
24
+ readonly detail: string;
25
+ /* The values the match names a language for, as offsets into the detail. */
26
+ readonly code: readonly EmbeddedCode[];
27
+ /* The words of the detail that are no option. */
28
+ readonly words: readonly CommandLookWord[];
29
+ /* The programs of the other commands in the line, each once, in the order they run. */
30
+ readonly others: readonly string[];
31
+ /* How many more commands of the line a look knows. */
32
+ readonly more: number;
33
+ /* Whether the command is fed a heredoc, which the detail leaves out. */
34
+ readonly heredoc: boolean;
35
+ }
36
+
37
+ const caches = new WeakMap<readonly CommandLook[], Map<string, CommandLookRun | null>>();
38
+
39
+ /* The look of a simple command and what it says about it, or null when no look knows it. */
40
+ function lookOf(command: ShellSimpleCommand, looks: readonly CommandLook[]): { look: CommandLook; match: CommandLookMatch } | null {
41
+ const program = command.program;
42
+ if (program === null) {
43
+ return null;
44
+ }
45
+ const args = command.words.slice(program.index + 1).map(({ text }) => text);
46
+ for (const look of looks) {
47
+ if (look.program !== program.name) {
48
+ continue;
49
+ }
50
+ const match = look.describe(args);
51
+ if (match !== null) {
52
+ return { look, match };
53
+ }
54
+ }
55
+ return null;
56
+ }
57
+
58
+ /* The source of `word`, with its value cut past the fold; the body comes back as offsets into what is returned. */
59
+ function foldedWord(source: string, word: ShellWord, fold: boolean): { text: string; body: { start: number; end: number } | null } {
60
+ const text = source.slice(word.start, word.end);
61
+ const body = word.body === null ? null : { start: word.body.start - word.start, end: word.body.end - word.start };
62
+ if (!fold) {
63
+ return { text, body };
64
+ }
65
+ if (body !== null && body.end - body.start > FOLD_LENGTH) {
66
+ const cut = body.start + FOLD_LENGTH;
67
+ return { text: `${text.slice(0, cut)}…${text.slice(body.end)}`, body: { start: body.start, end: cut + 1 } };
68
+ }
69
+ if (body === null && text.length > FOLD_LENGTH) {
70
+ return { text: `${text.slice(0, FOLD_LENGTH)}…`, body: null };
71
+ }
72
+ return { text, body };
73
+ }
74
+
75
+ /* The detail of a command from the word the match names: its words with their spacing, a redirect or heredoc between them a single space. */
76
+ function detailOf(source: string, command: ShellSimpleCommand, match: CommandLookMatch): Pick<CommandLookRun, 'detail' | 'code' | 'words'> {
77
+ const first = command.program!.index + 1 + Math.max(0, match.detailFrom);
78
+ let detail = '';
79
+ const code: EmbeddedCode[] = [];
80
+ const words: CommandLookWord[] = [];
81
+ let previous: ShellWord | null = null;
82
+ // The option the word before was, whose value this word may be.
83
+ let option: string | null = null;
84
+ for (const word of command.words.slice(first)) {
85
+ if (previous !== null) {
86
+ const gap = source.slice(previous.end, word.start);
87
+ detail += /^[ \t]*$/.test(gap) ? gap : ' ';
88
+ }
89
+ const start = detail.length;
90
+ const { text, body } = foldedWord(source, word, option !== null && match.fold?.includes(option) === true);
91
+ const language = option === null ? undefined : match.languages?.[option];
92
+ if (language !== undefined && body !== null && body.end > body.start) {
93
+ code.push({ start: start + body.start, end: start + body.end, language });
94
+ }
95
+ detail += text;
96
+ const isOption = word.text.startsWith('-') && word.text !== '-';
97
+ if (!isOption) {
98
+ words.push({ start, end: detail.length, text: word.text, option });
99
+ }
100
+ option = isOption ? word.text : null;
101
+ previous = word;
102
+ }
103
+ return { detail, code, words };
104
+ }
105
+
106
+ function read(command: string, looks: readonly CommandLook[]): CommandLookRun | null {
107
+ let found: { command: ShellSimpleCommand; look: CommandLook; match: CommandLookMatch } | null = null;
108
+ let more = 0;
109
+ const others: string[] = [];
110
+ for (const simple of shellCommands(command)) {
111
+ const known = lookOf(simple, looks);
112
+ if (known !== null && found === null) {
113
+ found = { command: simple, ...known };
114
+ } else if (known !== null) {
115
+ more++;
116
+ } else if (simple.program !== null && !others.includes(simple.program.name)) {
117
+ others.push(simple.program.name);
118
+ }
119
+ }
120
+ if (found === null) {
121
+ return null;
122
+ }
123
+ return {
124
+ look: found.look,
125
+ match: found.match,
126
+ ...detailOf(command, found.command, found.match),
127
+ others,
128
+ more,
129
+ heredoc: found.command.heredocs.length > 0
130
+ };
131
+ }
132
+
133
+ /*
134
+ * The first command of a shell line that one of `looks` knows, read as that look's tool: a program
135
+ * counts only where the shell runs it, past assignments and wrappers, so `grep tool` is no run of
136
+ * `tool`. Null for a line no look knows, and for one past the length that is drawn without looking inside.
137
+ */
138
+ export function matchCommandLook(command: string, looks: readonly CommandLook[] | undefined): CommandLookRun | null {
139
+ if (looks === undefined || looks.length === 0 || command.length > EMBEDDED_CODE_LIMIT || !looks.some(({ program }) => command.includes(program))) {
140
+ return null;
141
+ }
142
+ let cache = caches.get(looks);
143
+ if (cache === undefined) {
144
+ cache = new Map();
145
+ caches.set(looks, cache);
146
+ }
147
+ const known = cache.get(command);
148
+ if (known !== undefined) {
149
+ return known;
150
+ }
151
+ const run = read(command, looks);
152
+ cache.set(command, run);
153
+ if (cache.size > CACHE_LIMIT) {
154
+ cache.delete(cache.keys().next().value!);
155
+ }
156
+ return run;
157
+ }
@@ -12,22 +12,46 @@ export const SHELL_LANGUAGE = 'shellscript';
12
12
  export const EMBEDDED_CODE_LIMIT = 20_000;
13
13
  const MAX_DEPTH = 3;
14
14
 
15
- interface Span {
15
+ /* A stretch of a shell command, as offsets into it. */
16
+ export interface ShellSpan {
16
17
  readonly start: number;
17
18
  readonly end: number;
18
19
  }
19
20
 
20
- interface Word extends Span {
21
+ export interface ShellWord extends ShellSpan {
21
22
  /* The word as the shell hands it to the program, quotes and escapes taken out. */
22
23
  readonly text: string;
23
24
  /* The source of what the program gets, for a word that is bare or one quoted string; null for a mix. */
24
- readonly body: Span | null;
25
+ readonly body: ShellSpan | null;
25
26
  }
26
27
 
28
+ /* A redirect such as `2>&1` or `> out.txt`, its descriptor included. */
29
+ export interface ShellRedirect extends ShellSpan {
30
+ readonly op: string;
31
+ readonly target: ShellWord;
32
+ }
33
+
34
+ /* The `<<'EOF'` of a heredoc, and the lines it feeds the command. */
35
+ export interface ShellHeredoc extends ShellSpan {
36
+ readonly body: ShellSpan;
37
+ }
38
+
39
+ /* One simple command of a line, as a shell would split it: the words it runs and what it reads and writes. */
40
+ export interface ShellSimpleCommand {
41
+ readonly words: readonly ShellWord[];
42
+ /* The word it runs, past variable assignments, wrappers and runners; null for a command of assignments only. */
43
+ readonly program: { readonly index: number; readonly name: string } | null;
44
+ readonly redirects: readonly ShellRedirect[];
45
+ readonly heredocs: readonly ShellHeredoc[];
46
+ }
47
+
48
+ type Span = ShellSpan;
49
+ type Word = ShellWord;
50
+
27
51
  interface SimpleCommand {
28
52
  readonly words: Word[];
29
- readonly redirects: { readonly op: string; readonly target: Word }[];
30
- readonly heredocs: Span[];
53
+ readonly redirects: ShellRedirect[];
54
+ readonly heredocs: ShellHeredoc[];
31
55
  }
32
56
 
33
57
  interface Interpreter {
@@ -126,12 +150,15 @@ function programOf(word: string): string {
126
150
  function parseCommands(source: string, from: number, to: number): SimpleCommand[] {
127
151
  const commands: SimpleCommand[] = [];
128
152
  let current: SimpleCommand = { words: [], redirects: [], heredocs: [] };
129
- const pending: { command: SimpleCommand; delimiter: string; strip: boolean }[] = [];
153
+ const pending: { command: SimpleCommand; delimiter: string; strip: boolean; operator: Span }[] = [];
154
+ // The start of the `2` of a `2>&1` just read, which the redirect after it begins at.
155
+ let descriptorStart: number | null = null;
130
156
  const finish = (): void => {
131
157
  if (current.words.length > 0 || current.redirects.length > 0) {
132
158
  commands.push(current);
133
159
  }
134
160
  current = { words: [], redirects: [], heredocs: [] };
161
+ descriptorStart = null;
135
162
  };
136
163
  const skipBlanks = (index: number): number => {
137
164
  while (index < to && BLANKS.includes(source[index]!)) {
@@ -215,7 +242,7 @@ function parseCommands(source: string, from: number, to: number): SimpleCommand[
215
242
  index++;
216
243
  for (const heredoc of pending) {
217
244
  const { body, next } = readHeredoc(index, heredoc.delimiter, heredoc.strip);
218
- heredoc.command.heredocs.push(body);
245
+ heredoc.command.heredocs.push({ ...heredoc.operator, body });
219
246
  index = next;
220
247
  }
221
248
  pending.length = 0;
@@ -230,11 +257,12 @@ function parseCommands(source: string, from: number, to: number): SimpleCommand[
230
257
  } else if (source.startsWith('<<<', index)) {
231
258
  index = readWord(skipBlanks(index + 3)).end;
232
259
  } else if (source.startsWith('<<', index)) {
260
+ const start = index;
233
261
  index += 2;
234
262
  const strip = source[index] === '-';
235
263
  const word = readWord(skipBlanks(strip ? index + 1 : index));
236
264
  if (word.text !== '') {
237
- pending.push({ command: current, delimiter: word.text, strip });
265
+ pending.push({ command: current, delimiter: word.text, strip, operator: { start, end: word.end } });
238
266
  }
239
267
  index = word.end;
240
268
  } else if (char === '<' || char === '>' || (char === '&' && source[index + 1] === '>')) {
@@ -244,7 +272,8 @@ function parseCommands(source: string, from: number, to: number): SimpleCommand[
244
272
  }
245
273
  const op = source.slice(index, end);
246
274
  const target = readWord(skipBlanks(end));
247
- current.redirects.push({ op, target });
275
+ current.redirects.push({ start: descriptorStart ?? index, end: Math.max(target.end, end), op, target });
276
+ descriptorStart = null;
248
277
  index = Math.max(target.end, end);
249
278
  } else if (SEPARATORS.includes(char)) {
250
279
  index++;
@@ -253,7 +282,9 @@ function parseCommands(source: string, from: number, to: number): SimpleCommand[
253
282
  const word = readWord(index);
254
283
  // The `2` of `2>&1` is a file descriptor, not an argument.
255
284
  const descriptor = /^\d+$/.test(word.text) && (source[word.end] === '>' || source[word.end] === '<');
256
- if (!descriptor) {
285
+ if (descriptor) {
286
+ descriptorStart = word.start;
287
+ } else {
257
288
  current.words.push(word);
258
289
  }
259
290
  index = Math.max(word.end, index + 1);
@@ -350,7 +381,7 @@ function codeOf(command: SimpleCommand): EmbeddedCode[] {
350
381
  }
351
382
  const heredocLanguage = interpreter !== undefined && (!readsFile || interpreter.client === true) ? interpreter.language : writtenLanguage(command, program);
352
383
  if (heredocLanguage !== null) {
353
- for (const body of command.heredocs) {
384
+ for (const { body } of command.heredocs) {
354
385
  found.push({ ...body, language: heredocLanguage });
355
386
  }
356
387
  }
@@ -392,6 +423,21 @@ function collect(source: string, from: number, to: number, depth: number, into:
392
423
  }
393
424
  }
394
425
 
426
+ /*
427
+ * The simple commands of a shell line, in the order they come: a pipeline, a list and a line each split
428
+ * apart, a heredoc with the command it feeds. Only the line itself: the script of a `bash -lc '…'` stays a word.
429
+ */
430
+ export function shellCommands(source: string): ShellSimpleCommand[] {
431
+ return parseCommands(source, 0, source.length).map((command) => {
432
+ const index = programIndex(command.words);
433
+ const word = command.words[index];
434
+ return {
435
+ ...command,
436
+ program: word === undefined ? null : { index, name: word.text.slice(word.text.lastIndexOf('/') + 1) }
437
+ };
438
+ });
439
+ }
440
+
395
441
  /*
396
442
  * The code inside a shell command that is in another language, or a shell script of its own: the
397
443
  * script of `bash -lc '…'`, `python3 -c '…'`, `node -e '…'`, `psql -c '…'`, and a heredoc fed to an