@adecore/agents-react 0.25.8 → 0.26.0

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 (41) 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-copy.js +1 -1
  8. package/dist/chat/logic/timeline.d.ts +5 -3
  9. package/dist/chat/logic/timeline.js +64 -10
  10. package/dist/chat/ui/ShellCommand.d.ts +11 -1
  11. package/dist/chat/ui/ShellCommand.js +60 -20
  12. package/dist/chat/ui/SubagentTimeline.js +9 -1
  13. package/dist/chat/ui/Timeline.js +9 -2
  14. package/dist/chat/ui/rows/Rows.js +2 -2
  15. package/dist/chat/ui/rows/SubagentRow.js +1 -1
  16. package/dist/chat/ui/rows/WorkRows.d.ts +13 -4
  17. package/dist/chat/ui/rows/WorkRows.js +55 -11
  18. package/dist/chat/ui/rows/WorkflowRow.js +1 -1
  19. package/dist/host.d.ts +25 -0
  20. package/dist/index.d.ts +1 -1
  21. package/dist/locales/en/agent-chat.d.ts +4 -0
  22. package/dist/locales/en/agent-chat.js +6 -2
  23. package/dist/locales/nl/agent-chat.d.ts +4 -0
  24. package/dist/locales/nl/agent-chat.js +6 -2
  25. package/package.json +8 -8
  26. package/src/chat/index.ts +1 -0
  27. package/src/chat/logic/command-looks.ts +157 -0
  28. package/src/chat/logic/embedded-code.ts +57 -11
  29. package/src/chat/logic/timeline-copy.ts +1 -1
  30. package/src/chat/logic/timeline.ts +70 -13
  31. package/src/chat/ui/ShellCommand.tsx +83 -23
  32. package/src/chat/ui/SubagentTimeline.tsx +11 -2
  33. package/src/chat/ui/Timeline.tsx +9 -2
  34. package/src/chat/ui/rows/Rows.tsx +2 -2
  35. package/src/chat/ui/rows/SubagentRow.tsx +1 -1
  36. package/src/chat/ui/rows/WorkRows.tsx +136 -19
  37. package/src/chat/ui/rows/WorkflowRow.tsx +1 -1
  38. package/src/host.ts +43 -0
  39. package/src/index.ts +3 -0
  40. package/src/locales/en/agent-chat.ts +6 -2
  41. package/src/locales/nl/agent-chat.ts +6 -2
@@ -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
@@ -4,7 +4,7 @@ import { stripAnsi } from './ansi';
4
4
  /*
5
5
  * What a row of the thread puts on the clipboard. An answer is markdown on the wire and rendered
6
6
  * text on the screen, so copying it as text has to undo the marks the renderer swallowed; the
7
- * source itself stays available as "Copy as markdown".
7
+ * source itself stays available as "Copy as Markdown".
8
8
  */
9
9
 
10
10
  /*
@@ -14,12 +14,13 @@ import type {
14
14
  ChatWorkflow
15
15
  } from '@adecore/agent-contracts';
16
16
  import { abortedByMachine } from '@adecore/agent-contracts';
17
- import type { ThreadCard } from '../../host';
17
+ import type { CommandLook, ThreadCard } from '../../host';
18
18
  import { formatElapsedShort } from '@adecore/ui/format';
19
+ import { matchCommandLook } from './command-looks';
19
20
  import { isImageGeneration } from './generated-image';
20
21
  import { handbackReportOf } from './handback';
21
22
  import { toolEntry } from './tool-catalog';
22
- import { hasFileChanges, isFileChange } from './tools';
23
+ import { hasFileChanges, isFileChange, shellCommand } from './tools';
23
24
 
24
25
  /* A subagent's row: the work the thread kept of it, and the agents it opened in turn, each a row of its own under it. */
25
26
  export interface SubagentBranch {
@@ -43,7 +44,8 @@ export type TimelineRow =
43
44
  | { kind: 'report'; id: string; text: string }
44
45
  | { kind: 'thinking'; id: string; item: ChatThinkingItem }
45
46
  | { kind: 'work'; id: string; tool: ChatToolItem }
46
- | { kind: 'work-group'; id: string; tools: ChatToolItem[]; summary: string; expanded: boolean }
47
+ // `look` is set when every call is a run of that look and the summary is its sentence.
48
+ | { kind: 'work-group'; id: string; tools: ChatToolItem[]; summary: string; expanded: boolean; look?: CommandLook }
47
49
  | { kind: 'work-live'; id: string; tool: ChatToolItem }
48
50
  // The latest thought of a running turn, repeated over the call it led to; `at` is when that call began.
49
51
  | { kind: 'live-thought'; id: string; item: ChatThinkingItem; at: number }
@@ -70,6 +72,8 @@ interface TimelineOptions {
70
72
  activeTurnId: string | null;
71
73
  /* The settled turns a fork went on after; a thread that cannot fork leaves it out. */
72
74
  forkedTurns?: ReadonlySet<string>;
75
+ /* The host's programs drawn as tools of their own, whose runs read as their own sentences. */
76
+ commandLooks?: readonly CommandLook[];
73
77
  }
74
78
 
75
79
  /*
@@ -83,8 +87,42 @@ export function isBlock(row: TimelineRow): boolean {
83
87
  return BLOCK_KINDS.has(row.kind) || isImageRow(row);
84
88
  }
85
89
 
86
- /* "Read 4 files", "Ran 2 commands", or "12 tool calls" when the run mixes kinds. */
87
- export function summarizeGroup(tools: ChatToolItem[]): string {
90
+ /*
91
+ * The sentence of a run of shell calls that are all runs of one look: its group's when they share a key,
92
+ * else the look's own; null for any other run, which reads as shell calls.
93
+ */
94
+ function lookSummary(tools: readonly ChatToolItem[], looks: readonly CommandLook[] | undefined): { look: CommandLook; summary: string } | null {
95
+ let look: CommandLook | null = null;
96
+ let group: { key: string; sentence(count: number): string } | undefined;
97
+ let sameKey = true;
98
+ for (const tool of tools) {
99
+ const command = shellCommand(tool.name, tool.input);
100
+ const run = command === null ? null : matchCommandLook(command, looks);
101
+ if (run === null || (look !== null && run.look !== look)) {
102
+ return null;
103
+ }
104
+ if (look === null) {
105
+ look = run.look;
106
+ group = run.match.group;
107
+ } else if (run.match.group?.key !== group?.key) {
108
+ sameKey = false;
109
+ }
110
+ }
111
+ if (look === null) {
112
+ return null;
113
+ }
114
+ if (sameKey && group !== undefined) {
115
+ return { look, summary: group.sentence(tools.length) };
116
+ }
117
+ return look.sentence === undefined ? null : { look, summary: look.sentence(tools.length) };
118
+ }
119
+
120
+ /* "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. */
121
+ export function summarizeGroup(tools: ChatToolItem[], looks?: readonly CommandLook[]): string {
122
+ const looked = lookSummary(tools, looks);
123
+ if (looked !== null) {
124
+ return looked.summary;
125
+ }
88
126
  const names = new Set(tools.map((tool) => tool.name));
89
127
  const only = names.size === 1 ? tools[0]!.name : null;
90
128
  if (only !== null && toolEntry(only)?.grouped === true) {
@@ -110,23 +148,37 @@ function editedPath(tool: ChatToolItem): string {
110
148
  * "Searched 2 patterns". Every file change reads as one sentence, and calls without a sentence of
111
149
  * their own are counted together at the end.
112
150
  */
113
- export function summarizeTurn(tools: readonly ChatToolItem[]): string[] {
151
+ export function summarizeTurn(tools: readonly ChatToolItem[], looks?: readonly CommandLook[]): string[] {
114
152
  // Keyed by the sentence, so its place is where the first call of that kind came.
115
- const counts = new Map<string, number>();
153
+ const counts = new Map<string, { count: number; say(count: number): string }>();
154
+ const add = (key: string, say: (count: number) => string, total?: number): void => {
155
+ const known = counts.get(key);
156
+ if (known === undefined) {
157
+ counts.set(key, { count: total ?? 1, say });
158
+ } else {
159
+ known.count = total ?? known.count + 1;
160
+ }
161
+ };
116
162
  const edited = new Set<string>();
117
163
  let other = 0;
118
164
  for (const tool of tools) {
119
- if (isFileChange(tool.name)) {
165
+ const command = shellCommand(tool.name, tool.input);
166
+ const run = command === null ? null : matchCommandLook(command, looks);
167
+ const group = run?.match.group;
168
+ if (run !== null && group !== undefined) {
169
+ // Keyed by the program too, so two looks with a key in common keep their own sentences.
170
+ add(`look:${run.look.program}:${group.key}`, group.sentence);
171
+ } else if (isFileChange(tool.name)) {
120
172
  edited.add(editedPath(tool));
121
- counts.set('agent-chat:group.edited', edited.size);
173
+ add('agent-chat:group.edited', (count) => i18next.t('agent-chat:group.edited', { count }), edited.size);
122
174
  } else if (toolEntry(tool.name)?.grouped === true) {
123
175
  const key = `agent-chat:group.tools.${tool.name}`;
124
- counts.set(key, (counts.get(key) ?? 0) + 1);
176
+ add(key, (count) => i18next.t(key, { count }));
125
177
  } else {
126
178
  other += 1;
127
179
  }
128
180
  }
129
- const parts = [...counts].map(([key, count]) => i18next.t(key, { count }));
181
+ const parts = [...counts.values()].map(({ count, say }) => say(count));
130
182
  if (other > 0) {
131
183
  parts.push(i18next.t(parts.length === 0 ? 'agent-chat:group.toolCalls' : 'agent-chat:group.otherCalls', { count: other }));
132
184
  }
@@ -239,7 +291,12 @@ function flushTools(buffer: ChatToolItem[], rows: TimelineRow[], options: Timeli
239
291
  } else if (settled.length > 1) {
240
292
  const id = `group-${settled[0]!.id}`;
241
293
  const expanded = options.expandedGroups.has(id);
242
- rows.push({ kind: 'work-group', id, tools: settled, summary: summarizeGroup(settled), expanded });
294
+ const looked = lookSummary(settled, options.commandLooks);
295
+ rows.push(
296
+ looked === null
297
+ ? { kind: 'work-group', id, tools: settled, summary: summarizeGroup(settled), expanded }
298
+ : { kind: 'work-group', id, tools: settled, summary: looked.summary, expanded, look: looked.look }
299
+ );
243
300
  if (expanded) {
244
301
  for (const tool of settled) {
245
302
  rows.push({ kind: 'work', id: tool.id, tool });
@@ -462,7 +519,7 @@ export function deriveTimelineRows(items: ChatItem[], options: TimelineOptions):
462
519
  id: `fold-${turnId}`,
463
520
  turn,
464
521
  label: turnLabel(turn, chunk.items),
465
- work: summarizeTurn(tools),
522
+ work: summarizeTurn(tools, options.commandLooks),
466
523
  hiddenCount: folded.length,
467
524
  expanded
468
525
  });
@@ -1,8 +1,9 @@
1
1
  import { useEffect, useState, type ReactNode } from 'react';
2
2
  import type { BundledLanguage } from 'shiki';
3
- import { EMBEDDED_CODE_LIMIT, SHELL_LANGUAGE, embeddedCode } from '../logic/embedded-code';
3
+ import { Tooltip } from '@adecore/ui';
4
+ import { EMBEDDED_CODE_LIMIT, SHELL_LANGUAGE, embeddedCode, type EmbeddedCode } from '../logic/embedded-code';
4
5
  import type { CodeToken } from './code-lines';
5
- import { useChatHost } from '../../host';
6
+ import { useChatHost, type CommandName } from '../../host';
6
7
  import { useCodeTheme } from './code-theme';
7
8
  import { tokenStyle, useCodeHighlighter, type CodeHighlighter, type Highlighter } from './highlighter';
8
9
 
@@ -12,6 +13,17 @@ interface Run extends CodeToken {
12
13
  end: number;
13
14
  }
14
15
 
16
+ /* A word of a command drawn as the name a host gave it, as offsets into the command. */
17
+ export interface NamedWord {
18
+ readonly start: number;
19
+ readonly end: number;
20
+ readonly word: string;
21
+ readonly name: CommandName;
22
+ }
23
+
24
+ const NO_CODE: readonly EmbeddedCode[] = [];
25
+ const NO_NAMES: readonly NamedWord[] = [];
26
+
15
27
  // Rows redraw often and a thread holds many commands, so each is tokenized once per theme.
16
28
  const CACHE_LIMIT = 300;
17
29
  const cache = new Map<string, Run[]>();
@@ -47,13 +59,20 @@ function overlay(base: readonly Run[], start: number, end: number, inner: readon
47
59
  return [...kept, ...inner].sort((left, right) => left.start - right.start);
48
60
  }
49
61
 
50
- function languagesOf(command: string): Set<string> {
51
- return new Set([SHELL_LANGUAGE, ...embeddedCode(command).map(({ language }) => language)]);
62
+ /* The code inside a command, with the stretches a caller knows the language of on top. */
63
+ function codeIn(command: string, extra: readonly EmbeddedCode[]): EmbeddedCode[] {
64
+ return extra.length === 0
65
+ ? embeddedCode(command)
66
+ : [...embeddedCode(command), ...extra].sort((left, right) => left.start - right.start || right.end - left.end);
67
+ }
68
+
69
+ function languagesOf(command: string, extra: readonly EmbeddedCode[]): Set<string> {
70
+ return new Set([SHELL_LANGUAGE, ...codeIn(command, extra).map(({ language }) => language)]);
52
71
  }
53
72
 
54
73
  /* The colored runs of a command, or null while a grammar or the theme is still on its way. */
55
- function highlight(command: string, theme: string, code: CodeHighlighter): Run[] | null {
56
- const key = `${theme}\n${command}`;
74
+ function highlight(command: string, extra: readonly EmbeddedCode[], theme: string, code: CodeHighlighter): Run[] | null {
75
+ const key = `${theme}\n${extra.map(({ start, end, language }) => `${start}:${end}:${language}`).join(',')}\n${command}`;
57
76
  const known = cache.get(key);
58
77
  if (known !== undefined) {
59
78
  // Touched entries move to the end, so the oldest falls out first.
@@ -61,7 +80,7 @@ function highlight(command: string, theme: string, code: CodeHighlighter): Run[]
61
80
  cache.set(key, known);
62
81
  return known;
63
82
  }
64
- const embedded = embeddedCode(command);
83
+ const embedded = codeIn(command, extra);
65
84
  const highlighter = code.ready([SHELL_LANGUAGE, ...embedded.map(({ language }) => language)], theme);
66
85
  if (highlighter === null) {
67
86
  return null;
@@ -77,58 +96,99 @@ function highlight(command: string, theme: string, code: CodeHighlighter): Run[]
77
96
  return runs;
78
97
  }
79
98
 
80
- function draw(command: string, runs: readonly Run[]): ReactNode[] {
99
+ /* `command[from, to)` in its colors, or as it is while there are none. */
100
+ function draw(command: string, runs: readonly Run[] | null, from: number, to: number): ReactNode[] {
101
+ if (runs === null) {
102
+ return [command.slice(from, to)];
103
+ }
81
104
  const parts: ReactNode[] = [];
82
- let at = 0;
105
+ let at = from;
83
106
  for (const run of runs) {
107
+ if (run.end <= from || run.start >= to) {
108
+ continue;
109
+ }
110
+ const start = Math.max(run.start, from);
111
+ const end = Math.min(run.end, to);
84
112
  // Shiki leaves the line breaks out of its tokens; they come from the command itself.
85
- if (run.start > at) {
86
- parts.push(command.slice(at, run.start));
113
+ if (start > at) {
114
+ parts.push(command.slice(at, start));
87
115
  }
116
+ const content = run.content.slice(start - run.start, end - run.start);
88
117
  const style = tokenStyle(run);
89
118
  parts.push(
90
119
  style === undefined ? (
91
- run.content
120
+ content
92
121
  ) : (
93
- <span key={run.start} style={style}>
94
- {run.content}
122
+ <span key={start} style={style}>
123
+ {content}
95
124
  </span>
96
125
  )
97
126
  );
98
- at = run.end;
127
+ at = end;
128
+ }
129
+ if (at < to) {
130
+ parts.push(command.slice(at, to));
99
131
  }
100
- if (at < command.length) {
101
- parts.push(command.slice(at));
132
+ return parts;
133
+ }
134
+
135
+ /* A word as its name, in a frame so it reads as a reference; the word itself stays in the text, for a find. */
136
+ function Name({ named }: { named: NamedWord }) {
137
+ return (
138
+ <Tooltip label={named.word}>
139
+ <span className="inline-flex items-center gap-1 rounded bg-surface-hover px-1 font-sans text-text ring-1 ring-border ring-inset">
140
+ {named.name.icon}
141
+ {named.name.name}
142
+ <span className="sr-only">{named.word}</span>
143
+ </span>
144
+ </Tooltip>
145
+ );
146
+ }
147
+
148
+ function drawNamed(command: string, runs: readonly Run[] | null, names: readonly NamedWord[]): ReactNode[] {
149
+ const parts: ReactNode[] = [];
150
+ let at = 0;
151
+ for (const named of names) {
152
+ if (named.start < at) {
153
+ continue;
154
+ }
155
+ parts.push(...draw(command, runs, at, named.start), <Name key={`name-${named.start}`} named={named} />);
156
+ at = named.end;
102
157
  }
158
+ parts.push(...draw(command, runs, at, command.length));
103
159
  return parts;
104
160
  }
105
161
 
106
162
  /*
107
163
  * A shell command in the colors of the code theme, with the code inside it (a `bash -lc` script, a
108
164
  * `python3 -c` argument, a heredoc) in its own language. Plain text until the grammars are in, which
109
- * changes no size; a command past the limit stays plain.
165
+ * changes no size; a command past the limit stays plain. `code` adds stretches whose language the
166
+ * caller knows, and `names` draws words as the names a host gave them, both as offsets into `command`.
110
167
  */
111
- export function ShellCommand({ command }: { command: string }) {
168
+ export function ShellCommand({ command, code = NO_CODE, names = NO_NAMES }: { command: string; code?: readonly EmbeddedCode[]; names?: readonly NamedWord[] }) {
112
169
  const theme = useCodeTheme();
113
170
  const { custom } = useChatHost().code;
114
171
  const highlighter = useCodeHighlighter();
115
172
  // Bumped when a load settles, so the cache is asked again.
116
173
  const [, setLoads] = useState(0);
117
174
  const tooLong = command.length > EMBEDDED_CODE_LIMIT;
118
- const runs = tooLong ? null : highlight(command, theme, highlighter);
175
+ const runs = tooLong ? null : highlight(command, code, theme, highlighter);
119
176
 
120
177
  useEffect(() => {
121
178
  if (runs !== null || tooLong) {
122
179
  return;
123
180
  }
124
181
  let cancelled = false;
125
- Promise.all([highlighter.loadTheme(theme, custom), ...[...languagesOf(command)].map((lang) => highlighter.loadLanguage(lang))])
182
+ Promise.all([highlighter.loadTheme(theme, custom), ...[...languagesOf(command, code)].map((lang) => highlighter.loadLanguage(lang))])
126
183
  .then(() => !cancelled && setLoads((count) => count + 1))
127
184
  .catch(() => undefined);
128
185
  return () => {
129
186
  cancelled = true;
130
187
  };
131
- }, [highlighter, command, theme, custom, runs, tooLong]);
188
+ }, [highlighter, command, code, theme, custom, runs, tooLong]);
132
189
 
133
- return <>{runs === null ? command : draw(command, runs)}</>;
190
+ if (names.length > 0) {
191
+ return <>{drawNamed(command, runs, names)}</>;
192
+ }
193
+ return <>{runs === null ? command : draw(command, runs, 0, command.length)}</>;
134
194
  }
@@ -19,6 +19,7 @@ import { FileLinkContext } from './file-links';
19
19
  import { useChatRow } from '../../state/chats';
20
20
  import { useChatPreferences } from '../preferences';
21
21
  import { readingWidthStyle } from '../reading-width';
22
+ import { useChatHost } from '../../host';
22
23
  import { useChatScope } from '../../scope';
23
24
  import { EmptyState, ContextMenu, Icon } from '@adecore/ui';
24
25
 
@@ -31,6 +32,7 @@ const NO_TURNS = new Set<string>();
31
32
  export function SubagentTimeline({ chatId, toolUseId }: { chatId: string; toolUseId: string }) {
32
33
  const { t } = useTranslation('agent-chat');
33
34
  const scope = useChatScope();
35
+ const { commandLooks } = useChatHost();
34
36
  const connected = useSyncExternalStore(
35
37
  useCallback((changed) => scope.transport.subscribeStatus(changed), [scope]),
36
38
  useCallback(() => scope.transport.status === 'open', [scope]),
@@ -71,8 +73,15 @@ export function SubagentTimeline({ chatId, toolUseId }: { chatId: string; toolUs
71
73
  }, [state.unsupported, scope]);
72
74
 
73
75
  const rows = useMemo(
74
- () => deriveTimelineRows(state.items, { expandedGroups: groups.ids, expandedTurns: NO_TURNS, expandedSubagents: subagents.ids, activeTurnId: null }),
75
- [state.items, groups.ids, subagents.ids]
76
+ () =>
77
+ deriveTimelineRows(state.items, {
78
+ expandedGroups: groups.ids,
79
+ expandedTurns: NO_TURNS,
80
+ expandedSubagents: subagents.ids,
81
+ activeTurnId: null,
82
+ commandLooks
83
+ }),
84
+ [state.items, groups.ids, subagents.ids, commandLooks]
76
85
  );
77
86
 
78
87
  useLayoutEffect(() => {
@@ -252,9 +252,16 @@ export function Timeline({ chatId, composer, overlay }: { chatId: string; compos
252
252
  }
253
253
  return deriveTimelineRows(
254
254
  order.map((id) => items[id]!),
255
- { expandedGroups: groups.ids, expandedTurns: turns.ids, expandedSubagents: subagents.ids, activeTurnId, forkedTurns }
255
+ {
256
+ expandedGroups: groups.ids,
257
+ expandedTurns: turns.ids,
258
+ expandedSubagents: subagents.ids,
259
+ activeTurnId,
260
+ forkedTurns,
261
+ commandLooks: host.commandLooks
262
+ }
256
263
  );
257
- }, [order, items, groups.ids, turns.ids, subagents.ids, activeTurnId, forkedTurns]);
264
+ }, [order, items, groups.ids, turns.ids, subagents.ids, activeTurnId, forkedTurns, host.commandLooks]);
258
265
  const cards = host.useThreadCards(scopeId, chatId);
259
266
  const drawsVisuals = host.visuals !== null;
260
267
  // Where the part of the thread this client holds begins, while there is a page before it.
@@ -66,13 +66,13 @@ export function Row({ row, chatId, toggleGroup, toggleTurn, toggleSubagent, open
66
66
  case 'live-thought':
67
67
  return <LiveThoughtRow chatId={chatId} item={row.item} />;
68
68
  case 'work':
69
- return isImageGeneration(row.tool) ? <GeneratedImageRow chatId={chatId} tool={row.tool} /> : <WorkRow tool={row.tool} />;
69
+ return isImageGeneration(row.tool) ? <GeneratedImageRow chatId={chatId} tool={row.tool} /> : <WorkRow chatId={chatId} tool={row.tool} />;
70
70
  case 'work-live':
71
71
  return isImageGeneration(row.tool) ? <GeneratedImageRow chatId={chatId} tool={row.tool} /> : <WorkLiveRow chatId={chatId} tool={row.tool} />;
72
72
  case 'workflow':
73
73
  return <WorkflowRow chatId={chatId} tool={row.tool} workflow={row.workflow} onOpenAgent={openConversation} />;
74
74
  case 'work-group':
75
- return <WorkGroupRow tools={row.tools} summary={row.summary} expanded={row.expanded} onToggle={() => toggleGroup(row.id)} />;
75
+ return <WorkGroupRow tools={row.tools} summary={row.summary} look={row.look} expanded={row.expanded} onToggle={() => toggleGroup(row.id)} />;
76
76
  case 'subagent':
77
77
  return <SubagentBranchRow chatId={chatId} branch={row} onToggle={toggleSubagent} onOpenConversation={openConversation} />;
78
78
  case 'turn-fold':
@@ -32,7 +32,7 @@ function ChildRow({ chatId, item }: { chatId: string; item: ChatItem }) {
32
32
  if (isImageGeneration(item)) {
33
33
  return <GeneratedImageRow chatId={chatId} tool={item} />;
34
34
  }
35
- return item.state === 'running' ? <WorkLiveRow chatId={chatId} tool={item} /> : <WorkRow tool={item} />;
35
+ return item.state === 'running' ? <WorkLiveRow chatId={chatId} tool={item} /> : <WorkRow chatId={chatId} tool={item} />;
36
36
  }
37
37
  if (item.kind === 'assistant') {
38
38
  return <ChildText chatId={chatId} item={item} />;