@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
@@ -6,6 +6,7 @@ export { sendFromComposer, type ComposerSend } from './composer-send.js';
6
6
  export { EMPTY_DRAFT, useDrafts, readDraft, writeDraft, offerDraft, type ChatDraft } from './drafts.js';
7
7
  export { stripAnsi } from './logic/ansi.js';
8
8
  export { FORKABLE_PROVIDERS, forkRefusal, summaryRefusal, lastSettledTurn, forkPointOf, forkPointLabel, type ForkShape, forkShapes, type ForkCliChoice, forkPayload, branchRefusal, type ForkPoint, type ForkWorktreeChoice } from './logic/fork.js';
9
+ export { matchCommandLook, type CommandLookRun, type CommandLookWord } from './logic/command-looks.js';
9
10
  export { handbackReportOf } from './logic/handback.js';
10
11
  export { limitView, type LimitView } from './logic/limit.js';
11
12
  export type { TickKind, ScrubberTick } from './logic/scrubber.js';
@@ -6,6 +6,7 @@ export { sendFromComposer } from './composer-send.js';
6
6
  export { EMPTY_DRAFT, useDrafts, readDraft, writeDraft, offerDraft } from './drafts.js';
7
7
  export { stripAnsi } from './logic/ansi.js';
8
8
  export { FORKABLE_PROVIDERS, forkRefusal, summaryRefusal, lastSettledTurn, forkPointOf, forkPointLabel, forkShapes, forkPayload, branchRefusal } from './logic/fork.js';
9
+ export { matchCommandLook } from './logic/command-looks.js';
9
10
  export { handbackReportOf } from './logic/handback.js';
10
11
  export { limitView } from './logic/limit.js';
11
12
  export { withTimedRows, timedRowsOf } from './logic/thread-cards.js';
@@ -0,0 +1,19 @@
1
+ import type { CommandLook, CommandLookMatch } from '../../host.js';
2
+ import { type EmbeddedCode } from './embedded-code.js';
3
+ export interface CommandLookWord {
4
+ readonly start: number;
5
+ readonly end: number;
6
+ readonly text: string;
7
+ readonly option: string | null;
8
+ }
9
+ export interface CommandLookRun {
10
+ readonly look: CommandLook;
11
+ readonly match: CommandLookMatch;
12
+ readonly detail: string;
13
+ readonly code: readonly EmbeddedCode[];
14
+ readonly words: readonly CommandLookWord[];
15
+ readonly others: readonly string[];
16
+ readonly more: number;
17
+ readonly heredoc: boolean;
18
+ }
19
+ export declare function matchCommandLook(command: string, looks: readonly CommandLook[] | undefined): CommandLookRun | null;
@@ -0,0 +1,123 @@
1
+ import { EMBEDDED_CODE_LIMIT, shellCommands } from './embedded-code.js';
2
+ // A folded value keeps this many characters of what it says.
3
+ const FOLD_LENGTH = 40;
4
+ // Rows redraw often and a thread holds many commands, so each is read once per set of looks.
5
+ const CACHE_LIMIT = 300;
6
+ const caches = new WeakMap();
7
+ /* The look of a simple command and what it says about it, or null when no look knows it. */
8
+ function lookOf(command, looks) {
9
+ const program = command.program;
10
+ if (program === null) {
11
+ return null;
12
+ }
13
+ const args = command.words.slice(program.index + 1).map(({ text }) => text);
14
+ for (const look of looks) {
15
+ if (look.program !== program.name) {
16
+ continue;
17
+ }
18
+ const match = look.describe(args);
19
+ if (match !== null) {
20
+ return { look, match };
21
+ }
22
+ }
23
+ return null;
24
+ }
25
+ /* The source of `word`, with its value cut past the fold; the body comes back as offsets into what is returned. */
26
+ function foldedWord(source, word, fold) {
27
+ const text = source.slice(word.start, word.end);
28
+ const body = word.body === null ? null : { start: word.body.start - word.start, end: word.body.end - word.start };
29
+ if (!fold) {
30
+ return { text, body };
31
+ }
32
+ if (body !== null && body.end - body.start > FOLD_LENGTH) {
33
+ const cut = body.start + FOLD_LENGTH;
34
+ return { text: `${text.slice(0, cut)}…${text.slice(body.end)}`, body: { start: body.start, end: cut + 1 } };
35
+ }
36
+ if (body === null && text.length > FOLD_LENGTH) {
37
+ return { text: `${text.slice(0, FOLD_LENGTH)}…`, body: null };
38
+ }
39
+ return { text, body };
40
+ }
41
+ /* 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. */
42
+ function detailOf(source, command, match) {
43
+ const first = command.program.index + 1 + Math.max(0, match.detailFrom);
44
+ let detail = '';
45
+ const code = [];
46
+ const words = [];
47
+ let previous = null;
48
+ // The option the word before was, whose value this word may be.
49
+ let option = null;
50
+ for (const word of command.words.slice(first)) {
51
+ if (previous !== null) {
52
+ const gap = source.slice(previous.end, word.start);
53
+ detail += /^[ \t]*$/.test(gap) ? gap : ' ';
54
+ }
55
+ const start = detail.length;
56
+ const { text, body } = foldedWord(source, word, option !== null && match.fold?.includes(option) === true);
57
+ const language = option === null ? undefined : match.languages?.[option];
58
+ if (language !== undefined && body !== null && body.end > body.start) {
59
+ code.push({ start: start + body.start, end: start + body.end, language });
60
+ }
61
+ detail += text;
62
+ const isOption = word.text.startsWith('-') && word.text !== '-';
63
+ if (!isOption) {
64
+ words.push({ start, end: detail.length, text: word.text, option });
65
+ }
66
+ option = isOption ? word.text : null;
67
+ previous = word;
68
+ }
69
+ return { detail, code, words };
70
+ }
71
+ function read(command, looks) {
72
+ let found = null;
73
+ let more = 0;
74
+ const others = [];
75
+ for (const simple of shellCommands(command)) {
76
+ const known = lookOf(simple, looks);
77
+ if (known !== null && found === null) {
78
+ found = { command: simple, ...known };
79
+ }
80
+ else if (known !== null) {
81
+ more++;
82
+ }
83
+ else if (simple.program !== null && !others.includes(simple.program.name)) {
84
+ others.push(simple.program.name);
85
+ }
86
+ }
87
+ if (found === null) {
88
+ return null;
89
+ }
90
+ return {
91
+ look: found.look,
92
+ match: found.match,
93
+ ...detailOf(command, found.command, found.match),
94
+ others,
95
+ more,
96
+ heredoc: found.command.heredocs.length > 0
97
+ };
98
+ }
99
+ /*
100
+ * The first command of a shell line that one of `looks` knows, read as that look's tool: a program
101
+ * counts only where the shell runs it, past assignments and wrappers, so `grep tool` is no run of
102
+ * `tool`. Null for a line no look knows, and for one past the length that is drawn without looking inside.
103
+ */
104
+ export function matchCommandLook(command, looks) {
105
+ if (looks === undefined || looks.length === 0 || command.length > EMBEDDED_CODE_LIMIT || !looks.some(({ program }) => command.includes(program))) {
106
+ return null;
107
+ }
108
+ let cache = caches.get(looks);
109
+ if (cache === undefined) {
110
+ cache = new Map();
111
+ caches.set(looks, cache);
112
+ }
113
+ const known = cache.get(command);
114
+ if (known !== undefined) {
115
+ return known;
116
+ }
117
+ const run = read(command, looks);
118
+ cache.set(command, run);
119
+ if (cache.size > CACHE_LIMIT) {
120
+ cache.delete(cache.keys().next().value);
121
+ }
122
+ return run;
123
+ }
@@ -5,4 +5,29 @@ export interface EmbeddedCode {
5
5
  }
6
6
  export declare const SHELL_LANGUAGE = "shellscript";
7
7
  export declare const EMBEDDED_CODE_LIMIT = 20000;
8
+ export interface ShellSpan {
9
+ readonly start: number;
10
+ readonly end: number;
11
+ }
12
+ export interface ShellWord extends ShellSpan {
13
+ readonly text: string;
14
+ readonly body: ShellSpan | null;
15
+ }
16
+ export interface ShellRedirect extends ShellSpan {
17
+ readonly op: string;
18
+ readonly target: ShellWord;
19
+ }
20
+ export interface ShellHeredoc extends ShellSpan {
21
+ readonly body: ShellSpan;
22
+ }
23
+ export interface ShellSimpleCommand {
24
+ readonly words: readonly ShellWord[];
25
+ readonly program: {
26
+ readonly index: number;
27
+ readonly name: string;
28
+ } | null;
29
+ readonly redirects: readonly ShellRedirect[];
30
+ readonly heredocs: readonly ShellHeredoc[];
31
+ }
32
+ export declare function shellCommands(source: string): ShellSimpleCommand[];
8
33
  export declare function embeddedCode(command: string): EmbeddedCode[];
@@ -78,11 +78,14 @@ function parseCommands(source, from, to) {
78
78
  const commands = [];
79
79
  let current = { words: [], redirects: [], heredocs: [] };
80
80
  const pending = [];
81
+ // The start of the `2` of a `2>&1` just read, which the redirect after it begins at.
82
+ let descriptorStart = null;
81
83
  const finish = () => {
82
84
  if (current.words.length > 0 || current.redirects.length > 0) {
83
85
  commands.push(current);
84
86
  }
85
87
  current = { words: [], redirects: [], heredocs: [] };
88
+ descriptorStart = null;
86
89
  };
87
90
  const skipBlanks = (index) => {
88
91
  while (index < to && BLANKS.includes(source[index])) {
@@ -168,7 +171,7 @@ function parseCommands(source, from, to) {
168
171
  index++;
169
172
  for (const heredoc of pending) {
170
173
  const { body, next } = readHeredoc(index, heredoc.delimiter, heredoc.strip);
171
- heredoc.command.heredocs.push(body);
174
+ heredoc.command.heredocs.push({ ...heredoc.operator, body });
172
175
  index = next;
173
176
  }
174
177
  pending.length = 0;
@@ -188,11 +191,12 @@ function parseCommands(source, from, to) {
188
191
  index = readWord(skipBlanks(index + 3)).end;
189
192
  }
190
193
  else if (source.startsWith('<<', index)) {
194
+ const start = index;
191
195
  index += 2;
192
196
  const strip = source[index] === '-';
193
197
  const word = readWord(skipBlanks(strip ? index + 1 : index));
194
198
  if (word.text !== '') {
195
- pending.push({ command: current, delimiter: word.text, strip });
199
+ pending.push({ command: current, delimiter: word.text, strip, operator: { start, end: word.end } });
196
200
  }
197
201
  index = word.end;
198
202
  }
@@ -203,7 +207,8 @@ function parseCommands(source, from, to) {
203
207
  }
204
208
  const op = source.slice(index, end);
205
209
  const target = readWord(skipBlanks(end));
206
- current.redirects.push({ op, target });
210
+ current.redirects.push({ start: descriptorStart ?? index, end: Math.max(target.end, end), op, target });
211
+ descriptorStart = null;
207
212
  index = Math.max(target.end, end);
208
213
  }
209
214
  else if (SEPARATORS.includes(char)) {
@@ -214,7 +219,10 @@ function parseCommands(source, from, to) {
214
219
  const word = readWord(index);
215
220
  // The `2` of `2>&1` is a file descriptor, not an argument.
216
221
  const descriptor = /^\d+$/.test(word.text) && (source[word.end] === '>' || source[word.end] === '<');
217
- if (!descriptor) {
222
+ if (descriptor) {
223
+ descriptorStart = word.start;
224
+ }
225
+ else {
218
226
  current.words.push(word);
219
227
  }
220
228
  index = Math.max(word.end, index + 1);
@@ -317,7 +325,7 @@ function codeOf(command) {
317
325
  }
318
326
  const heredocLanguage = interpreter !== undefined && (!readsFile || interpreter.client === true) ? interpreter.language : writtenLanguage(command, program);
319
327
  if (heredocLanguage !== null) {
320
- for (const body of command.heredocs) {
328
+ for (const { body } of command.heredocs) {
321
329
  found.push({ ...body, language: heredocLanguage });
322
330
  }
323
331
  }
@@ -354,6 +362,20 @@ function collect(source, from, to, depth, into) {
354
362
  }
355
363
  }
356
364
  }
365
+ /*
366
+ * The simple commands of a shell line, in the order they come: a pipeline, a list and a line each split
367
+ * apart, a heredoc with the command it feeds. Only the line itself: the script of a `bash -lc '…'` stays a word.
368
+ */
369
+ export function shellCommands(source) {
370
+ return parseCommands(source, 0, source.length).map((command) => {
371
+ const index = programIndex(command.words);
372
+ const word = command.words[index];
373
+ return {
374
+ ...command,
375
+ program: word === undefined ? null : { index, name: word.text.slice(word.text.lastIndexOf('/') + 1) }
376
+ };
377
+ });
378
+ }
357
379
  /*
358
380
  * The code inside a shell command that is in another language, or a shell script of its own: the
359
381
  * script of `bash -lc '…'`, `python3 -c '…'`, `node -e '…'`, `psql -c '…'`, and a heredoc fed to an
@@ -1,5 +1,5 @@
1
1
  import type { ChatApprovalItem, ChatAssistantItem, ChatCheckpointDiff, ChatItem, ChatQuestionItem, ChatSubagentItem, ChatThinkingItem, ChatToolItem, ChatTurnItem, ChatUserItem, ChatVisual, ChatWorkflow } from '@adecore/agent-contracts';
2
- import type { ThreadCard } from '../../host.js';
2
+ import type { CommandLook, ThreadCard } from '../../host.js';
3
3
  export interface SubagentBranch {
4
4
  id: string;
5
5
  item: ChatSubagentItem;
@@ -38,6 +38,7 @@ export type TimelineRow = {
38
38
  tools: ChatToolItem[];
39
39
  summary: string;
40
40
  expanded: boolean;
41
+ look?: CommandLook;
41
42
  } | {
42
43
  kind: 'work-live';
43
44
  id: string;
@@ -111,10 +112,11 @@ interface TimelineOptions {
111
112
  expandedSubagents: ReadonlySet<string>;
112
113
  activeTurnId: string | null;
113
114
  forkedTurns?: ReadonlySet<string>;
115
+ commandLooks?: readonly CommandLook[];
114
116
  }
115
117
  export declare function isBlock(row: TimelineRow): boolean;
116
- export declare function summarizeGroup(tools: ChatToolItem[]): string;
117
- export declare function summarizeTurn(tools: readonly ChatToolItem[]): string[];
118
+ export declare function summarizeGroup(tools: ChatToolItem[], looks?: readonly CommandLook[]): string;
119
+ export declare function summarizeTurn(tools: readonly ChatToolItem[], looks?: readonly CommandLook[]): string[];
118
120
  export declare function agentTurnLabel(turn: ChatTurnItem): string;
119
121
  export declare function turnLabel(turn: ChatTurnItem, items?: readonly ChatItem[]): string;
120
122
  export declare function findSubagentBranch(rows: readonly TimelineRow[], toolUseId: string): {
@@ -1,10 +1,11 @@
1
1
  import i18next from 'i18next';
2
2
  import { abortedByMachine } from '@adecore/agent-contracts';
3
3
  import { formatElapsedShort } from '@adecore/ui/format';
4
+ import { matchCommandLook } from './command-looks.js';
4
5
  import { isImageGeneration } from './generated-image.js';
5
6
  import { handbackReportOf } from './handback.js';
6
7
  import { toolEntry } from './tool-catalog.js';
7
- import { hasFileChanges, isFileChange } from './tools.js';
8
+ import { hasFileChanges, isFileChange, shellCommand } from './tools.js';
8
9
  /*
9
10
  * A turn runs in two rhythms. The tool lines are a list and read as one when they sit tight
10
11
  * against each other; prose and cards are blocks and need room around them. Where the two meet,
@@ -14,8 +15,42 @@ const BLOCK_KINDS = new Set(['assistant', 'report', 'thinking', 'changed-files',
14
15
  export function isBlock(row) {
15
16
  return BLOCK_KINDS.has(row.kind) || isImageRow(row);
16
17
  }
17
- /* "Read 4 files", "Ran 2 commands", or "12 tool calls" when the run mixes kinds. */
18
- export function summarizeGroup(tools) {
18
+ /*
19
+ * The sentence of a run of shell calls that are all runs of one look: its group's when they share a key,
20
+ * else the look's own; null for any other run, which reads as shell calls.
21
+ */
22
+ function lookSummary(tools, looks) {
23
+ let look = null;
24
+ let group;
25
+ let sameKey = true;
26
+ for (const tool of tools) {
27
+ const command = shellCommand(tool.name, tool.input);
28
+ const run = command === null ? null : matchCommandLook(command, looks);
29
+ if (run === null || (look !== null && run.look !== look)) {
30
+ return null;
31
+ }
32
+ if (look === null) {
33
+ look = run.look;
34
+ group = run.match.group;
35
+ }
36
+ else if (run.match.group?.key !== group?.key) {
37
+ sameKey = false;
38
+ }
39
+ }
40
+ if (look === null) {
41
+ return null;
42
+ }
43
+ if (sameKey && group !== undefined) {
44
+ return { look, summary: group.sentence(tools.length) };
45
+ }
46
+ return look.sentence === undefined ? null : { look, summary: look.sentence(tools.length) };
47
+ }
48
+ /* "Read 4 files", "Ran 2 commands", "12 tool calls" when the run mixes kinds, or the sentence of a look all its calls are runs of. */
49
+ export function summarizeGroup(tools, looks) {
50
+ const looked = lookSummary(tools, looks);
51
+ if (looked !== null) {
52
+ return looked.summary;
53
+ }
19
54
  const names = new Set(tools.map((tool) => tool.name));
20
55
  const only = names.size === 1 ? tools[0].name : null;
21
56
  if (only !== null && toolEntry(only)?.grouped === true) {
@@ -39,25 +74,41 @@ function editedPath(tool) {
39
74
  * "Searched 2 patterns". Every file change reads as one sentence, and calls without a sentence of
40
75
  * their own are counted together at the end.
41
76
  */
42
- export function summarizeTurn(tools) {
77
+ export function summarizeTurn(tools, looks) {
43
78
  // Keyed by the sentence, so its place is where the first call of that kind came.
44
79
  const counts = new Map();
80
+ const add = (key, say, total) => {
81
+ const known = counts.get(key);
82
+ if (known === undefined) {
83
+ counts.set(key, { count: total ?? 1, say });
84
+ }
85
+ else {
86
+ known.count = total ?? known.count + 1;
87
+ }
88
+ };
45
89
  const edited = new Set();
46
90
  let other = 0;
47
91
  for (const tool of tools) {
48
- if (isFileChange(tool.name)) {
92
+ const command = shellCommand(tool.name, tool.input);
93
+ const run = command === null ? null : matchCommandLook(command, looks);
94
+ const group = run?.match.group;
95
+ if (run !== null && group !== undefined) {
96
+ // Keyed by the program too, so two looks with a key in common keep their own sentences.
97
+ add(`look:${run.look.program}:${group.key}`, group.sentence);
98
+ }
99
+ else if (isFileChange(tool.name)) {
49
100
  edited.add(editedPath(tool));
50
- counts.set('agent-chat:group.edited', edited.size);
101
+ add('agent-chat:group.edited', (count) => i18next.t('agent-chat:group.edited', { count }), edited.size);
51
102
  }
52
103
  else if (toolEntry(tool.name)?.grouped === true) {
53
104
  const key = `agent-chat:group.tools.${tool.name}`;
54
- counts.set(key, (counts.get(key) ?? 0) + 1);
105
+ add(key, (count) => i18next.t(key, { count }));
55
106
  }
56
107
  else {
57
108
  other += 1;
58
109
  }
59
110
  }
60
- const parts = [...counts].map(([key, count]) => i18next.t(key, { count }));
111
+ const parts = [...counts.values()].map(({ count, say }) => say(count));
61
112
  if (other > 0) {
62
113
  parts.push(i18next.t(parts.length === 0 ? 'agent-chat:group.toolCalls' : 'agent-chat:group.otherCalls', { count: other }));
63
114
  }
@@ -163,7 +214,10 @@ function flushTools(buffer, rows, options) {
163
214
  else if (settled.length > 1) {
164
215
  const id = `group-${settled[0].id}`;
165
216
  const expanded = options.expandedGroups.has(id);
166
- rows.push({ kind: 'work-group', id, tools: settled, summary: summarizeGroup(settled), expanded });
217
+ const looked = lookSummary(settled, options.commandLooks);
218
+ rows.push(looked === null
219
+ ? { kind: 'work-group', id, tools: settled, summary: summarizeGroup(settled), expanded }
220
+ : { kind: 'work-group', id, tools: settled, summary: looked.summary, expanded, look: looked.look });
167
221
  if (expanded) {
168
222
  for (const tool of settled) {
169
223
  rows.push({ kind: 'work', id: tool.id, tool });
@@ -365,7 +419,7 @@ export function deriveTimelineRows(items, options) {
365
419
  id: `fold-${turnId}`,
366
420
  turn,
367
421
  label: turnLabel(turn, chunk.items),
368
- work: summarizeTurn(tools),
422
+ work: summarizeTurn(tools, options.commandLooks),
369
423
  hiddenCount: folded.length,
370
424
  expanded
371
425
  });
@@ -1,3 +1,13 @@
1
- export declare function ShellCommand({ command }: {
1
+ import { type EmbeddedCode } from '../logic/embedded-code.js';
2
+ import { type CommandName } from '../../host.js';
3
+ export interface NamedWord {
4
+ readonly start: number;
5
+ readonly end: number;
6
+ readonly word: string;
7
+ readonly name: CommandName;
8
+ }
9
+ export declare function ShellCommand({ command, code, names }: {
2
10
  command: string;
11
+ code?: readonly EmbeddedCode[];
12
+ names?: readonly NamedWord[];
3
13
  }): import("react").JSX.Element;
@@ -1,9 +1,12 @@
1
- import { Fragment as _Fragment, jsx as _jsx } from "react/jsx-runtime";
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useEffect, useState } from 'react';
3
+ import { Tooltip } from '@adecore/ui';
3
4
  import { EMBEDDED_CODE_LIMIT, SHELL_LANGUAGE, embeddedCode } from '../logic/embedded-code.js';
4
5
  import { useChatHost } from '../../host.js';
5
6
  import { useCodeTheme } from './code-theme.js';
6
7
  import { tokenStyle, useCodeHighlighter } from './highlighter.js';
8
+ const NO_CODE = [];
9
+ const NO_NAMES = [];
7
10
  // Rows redraw often and a thread holds many commands, so each is tokenized once per theme.
8
11
  const CACHE_LIMIT = 300;
9
12
  const cache = new Map();
@@ -36,12 +39,18 @@ function overlay(base, start, end, inner) {
36
39
  }
37
40
  return [...kept, ...inner].sort((left, right) => left.start - right.start);
38
41
  }
39
- function languagesOf(command) {
40
- return new Set([SHELL_LANGUAGE, ...embeddedCode(command).map(({ language }) => language)]);
42
+ /* The code inside a command, with the stretches a caller knows the language of on top. */
43
+ function codeIn(command, extra) {
44
+ return extra.length === 0
45
+ ? embeddedCode(command)
46
+ : [...embeddedCode(command), ...extra].sort((left, right) => left.start - right.start || right.end - left.end);
47
+ }
48
+ function languagesOf(command, extra) {
49
+ return new Set([SHELL_LANGUAGE, ...codeIn(command, extra).map(({ language }) => language)]);
41
50
  }
42
51
  /* The colored runs of a command, or null while a grammar or the theme is still on its way. */
43
- function highlight(command, theme, code) {
44
- const key = `${theme}\n${command}`;
52
+ function highlight(command, extra, theme, code) {
53
+ const key = `${theme}\n${extra.map(({ start, end, language }) => `${start}:${end}:${language}`).join(',')}\n${command}`;
45
54
  const known = cache.get(key);
46
55
  if (known !== undefined) {
47
56
  // Touched entries move to the end, so the oldest falls out first.
@@ -49,7 +58,7 @@ function highlight(command, theme, code) {
49
58
  cache.set(key, known);
50
59
  return known;
51
60
  }
52
- const embedded = embeddedCode(command);
61
+ const embedded = codeIn(command, extra);
53
62
  const highlighter = code.ready([SHELL_LANGUAGE, ...embedded.map(({ language }) => language)], theme);
54
63
  if (highlighter === null) {
55
64
  return null;
@@ -64,47 +73,78 @@ function highlight(command, theme, code) {
64
73
  }
65
74
  return runs;
66
75
  }
67
- function draw(command, runs) {
76
+ /* `command[from, to)` in its colors, or as it is while there are none. */
77
+ function draw(command, runs, from, to) {
78
+ if (runs === null) {
79
+ return [command.slice(from, to)];
80
+ }
68
81
  const parts = [];
69
- let at = 0;
82
+ let at = from;
70
83
  for (const run of runs) {
84
+ if (run.end <= from || run.start >= to) {
85
+ continue;
86
+ }
87
+ const start = Math.max(run.start, from);
88
+ const end = Math.min(run.end, to);
71
89
  // Shiki leaves the line breaks out of its tokens; they come from the command itself.
72
- if (run.start > at) {
73
- parts.push(command.slice(at, run.start));
90
+ if (start > at) {
91
+ parts.push(command.slice(at, start));
74
92
  }
93
+ const content = run.content.slice(start - run.start, end - run.start);
75
94
  const style = tokenStyle(run);
76
- parts.push(style === undefined ? (run.content) : (_jsx("span", { style: style, children: run.content }, run.start)));
77
- at = run.end;
95
+ parts.push(style === undefined ? (content) : (_jsx("span", { style: style, children: content }, start)));
96
+ at = end;
97
+ }
98
+ if (at < to) {
99
+ parts.push(command.slice(at, to));
78
100
  }
79
- if (at < command.length) {
80
- parts.push(command.slice(at));
101
+ return parts;
102
+ }
103
+ /* A word as its name, in a frame so it reads as a reference; the word itself stays in the text, for a find. */
104
+ function Name({ named }) {
105
+ return (_jsx(Tooltip, { label: named.word, children: _jsxs("span", { className: "inline-flex items-center gap-1 rounded bg-surface-hover px-1 font-sans text-text ring-1 ring-border ring-inset", children: [named.name.icon, named.name.name, _jsx("span", { className: "sr-only", children: named.word })] }) }));
106
+ }
107
+ function drawNamed(command, runs, names) {
108
+ const parts = [];
109
+ let at = 0;
110
+ for (const named of names) {
111
+ if (named.start < at) {
112
+ continue;
113
+ }
114
+ parts.push(...draw(command, runs, at, named.start), _jsx(Name, { named: named }, `name-${named.start}`));
115
+ at = named.end;
81
116
  }
117
+ parts.push(...draw(command, runs, at, command.length));
82
118
  return parts;
83
119
  }
84
120
  /*
85
121
  * A shell command in the colors of the code theme, with the code inside it (a `bash -lc` script, a
86
122
  * `python3 -c` argument, a heredoc) in its own language. Plain text until the grammars are in, which
87
- * changes no size; a command past the limit stays plain.
123
+ * changes no size; a command past the limit stays plain. `code` adds stretches whose language the
124
+ * caller knows, and `names` draws words as the names a host gave them, both as offsets into `command`.
88
125
  */
89
- export function ShellCommand({ command }) {
126
+ export function ShellCommand({ command, code = NO_CODE, names = NO_NAMES }) {
90
127
  const theme = useCodeTheme();
91
128
  const { custom } = useChatHost().code;
92
129
  const highlighter = useCodeHighlighter();
93
130
  // Bumped when a load settles, so the cache is asked again.
94
131
  const [, setLoads] = useState(0);
95
132
  const tooLong = command.length > EMBEDDED_CODE_LIMIT;
96
- const runs = tooLong ? null : highlight(command, theme, highlighter);
133
+ const runs = tooLong ? null : highlight(command, code, theme, highlighter);
97
134
  useEffect(() => {
98
135
  if (runs !== null || tooLong) {
99
136
  return;
100
137
  }
101
138
  let cancelled = false;
102
- Promise.all([highlighter.loadTheme(theme, custom), ...[...languagesOf(command)].map((lang) => highlighter.loadLanguage(lang))])
139
+ Promise.all([highlighter.loadTheme(theme, custom), ...[...languagesOf(command, code)].map((lang) => highlighter.loadLanguage(lang))])
103
140
  .then(() => !cancelled && setLoads((count) => count + 1))
104
141
  .catch(() => undefined);
105
142
  return () => {
106
143
  cancelled = true;
107
144
  };
108
- }, [highlighter, command, theme, custom, runs, tooLong]);
109
- return _jsx(_Fragment, { children: runs === null ? command : draw(command, runs) });
145
+ }, [highlighter, command, code, theme, custom, runs, tooLong]);
146
+ if (names.length > 0) {
147
+ return _jsx(_Fragment, { children: drawNamed(command, runs, names) });
148
+ }
149
+ return _jsx(_Fragment, { children: runs === null ? command : draw(command, runs, 0, command.length) });
110
150
  }
@@ -20,6 +20,7 @@ import { FileLinkContext } from './file-links.js';
20
20
  import { useChatRow } from '../../state/chats.js';
21
21
  import { useChatPreferences } from '../preferences.js';
22
22
  import { readingWidthStyle } from '../reading-width.js';
23
+ import { useChatHost } from '../../host.js';
23
24
  import { useChatScope } from '../../scope.js';
24
25
  import { EmptyState, ContextMenu, Icon } from '@adecore/ui';
25
26
  const NO_TURNS = new Set();
@@ -30,6 +31,7 @@ const NO_TURNS = new Set();
30
31
  export function SubagentTimeline({ chatId, toolUseId }) {
31
32
  const { t } = useTranslation('agent-chat');
32
33
  const scope = useChatScope();
34
+ const { commandLooks } = useChatHost();
33
35
  const connected = useSyncExternalStore(useCallback((changed) => scope.transport.subscribeStatus(changed), [scope]), useCallback(() => scope.transport.status === 'open', [scope]), () => false);
34
36
  const { trail, show } = useSubagentTrail(chatId);
35
37
  const [state, setState] = useState(INITIAL_CONVERSATION);
@@ -62,7 +64,13 @@ export function SubagentTimeline({ chatId, toolUseId }) {
62
64
  useSubagentSupport.getState().markUnsupported(scope.id);
63
65
  }
64
66
  }, [state.unsupported, scope]);
65
- const rows = useMemo(() => deriveTimelineRows(state.items, { expandedGroups: groups.ids, expandedTurns: NO_TURNS, expandedSubagents: subagents.ids, activeTurnId: null }), [state.items, groups.ids, subagents.ids]);
67
+ const rows = useMemo(() => deriveTimelineRows(state.items, {
68
+ expandedGroups: groups.ids,
69
+ expandedTurns: NO_TURNS,
70
+ expandedSubagents: subagents.ids,
71
+ activeTurnId: null,
72
+ commandLooks
73
+ }), [state.items, groups.ids, subagents.ids, commandLooks]);
66
74
  useLayoutEffect(() => {
67
75
  const element = scrollRef.current;
68
76
  if (element === null) {
@@ -145,8 +145,15 @@ export function Timeline({ chatId, composer, overlay }) {
145
145
  if (!order || !items) {
146
146
  return [];
147
147
  }
148
- return deriveTimelineRows(order.map((id) => items[id]), { expandedGroups: groups.ids, expandedTurns: turns.ids, expandedSubagents: subagents.ids, activeTurnId, forkedTurns });
149
- }, [order, items, groups.ids, turns.ids, subagents.ids, activeTurnId, forkedTurns]);
148
+ return deriveTimelineRows(order.map((id) => items[id]), {
149
+ expandedGroups: groups.ids,
150
+ expandedTurns: turns.ids,
151
+ expandedSubagents: subagents.ids,
152
+ activeTurnId,
153
+ forkedTurns,
154
+ commandLooks: host.commandLooks
155
+ });
156
+ }, [order, items, groups.ids, turns.ids, subagents.ids, activeTurnId, forkedTurns, host.commandLooks]);
150
157
  const cards = host.useThreadCards(scopeId, chatId);
151
158
  const drawsVisuals = host.visuals !== null;
152
159
  // Where the part of the thread this client holds begins, while there is a page before it.
@@ -35,13 +35,13 @@ export function Row({ row, chatId, toggleGroup, toggleTurn, toggleSubagent, open
35
35
  case 'live-thought':
36
36
  return _jsx(LiveThoughtRow, { chatId: chatId, item: row.item });
37
37
  case 'work':
38
- return isImageGeneration(row.tool) ? _jsx(GeneratedImageRow, { chatId: chatId, tool: row.tool }) : _jsx(WorkRow, { tool: row.tool });
38
+ return isImageGeneration(row.tool) ? _jsx(GeneratedImageRow, { chatId: chatId, tool: row.tool }) : _jsx(WorkRow, { chatId: chatId, tool: row.tool });
39
39
  case 'work-live':
40
40
  return isImageGeneration(row.tool) ? _jsx(GeneratedImageRow, { chatId: chatId, tool: row.tool }) : _jsx(WorkLiveRow, { chatId: chatId, tool: row.tool });
41
41
  case 'workflow':
42
42
  return _jsx(WorkflowRow, { chatId: chatId, tool: row.tool, workflow: row.workflow, onOpenAgent: openConversation });
43
43
  case 'work-group':
44
- return _jsx(WorkGroupRow, { tools: row.tools, summary: row.summary, expanded: row.expanded, onToggle: () => toggleGroup(row.id) });
44
+ return _jsx(WorkGroupRow, { tools: row.tools, summary: row.summary, look: row.look, expanded: row.expanded, onToggle: () => toggleGroup(row.id) });
45
45
  case 'subagent':
46
46
  return _jsx(SubagentBranchRow, { chatId: chatId, branch: row, onToggle: toggleSubagent, onOpenConversation: openConversation });
47
47
  case 'turn-fold':