@adecore/agents-react 0.21.1 → 0.22.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 (145) hide show
  1. package/dist/agents/model-requirement.d.ts +6 -0
  2. package/dist/agents/model-requirement.js +26 -0
  3. package/dist/agents/status-look.js +2 -3
  4. package/dist/chat/chat-client.d.ts +3 -2
  5. package/dist/chat/chat-client.js +4 -2
  6. package/dist/chat/drafts.d.ts +2 -1
  7. package/dist/chat/drafts.js +21 -17
  8. package/dist/chat/logic/ansi.js +5 -1
  9. package/dist/chat/logic/fork.js +8 -9
  10. package/dist/chat/logic/limit.js +17 -14
  11. package/dist/chat/logic/picker-entries.d.ts +10 -0
  12. package/dist/chat/logic/picker-entries.js +19 -0
  13. package/dist/chat/logic/resume-compaction.js +2 -11
  14. package/dist/chat/logic/scrubber.js +9 -18
  15. package/dist/chat/logic/timeline.d.ts +5 -0
  16. package/dist/chat/logic/timeline.js +25 -11
  17. package/dist/chat/logic/tool-catalog.js +3 -4
  18. package/dist/chat/mentions.js +5 -6
  19. package/dist/chat/prompt-hold.d.ts +8 -0
  20. package/dist/chat/prompt-hold.js +16 -0
  21. package/dist/chat/quote.d.ts +4 -0
  22. package/dist/chat/quote.js +19 -0
  23. package/dist/chat/runtime-modes.js +2 -4
  24. package/dist/chat/stash.d.ts +8 -2
  25. package/dist/chat/stash.js +22 -8
  26. package/dist/chat/subagent-list.d.ts +7 -1
  27. package/dist/chat/subagent-list.js +13 -1
  28. package/dist/chat/thumbnails.js +3 -4
  29. package/dist/chat/timeline-scroll.d.ts +6 -2
  30. package/dist/chat/timeline-scroll.js +45 -46
  31. package/dist/chat/ui/BookmarkSubmenu.js +1 -5
  32. package/dist/chat/ui/ChatActivity.js +22 -8
  33. package/dist/chat/ui/CodeBlock.js +2 -3
  34. package/dist/chat/ui/Composer.js +160 -88
  35. package/dist/chat/ui/ComposerInput.d.ts +3 -1
  36. package/dist/chat/ui/ComposerInput.js +13 -4
  37. package/dist/chat/ui/Markdown.js +11 -11
  38. package/dist/chat/ui/Pickers.js +31 -27
  39. package/dist/chat/ui/PromptComposer.d.ts +8 -1
  40. package/dist/chat/ui/PromptComposer.js +18 -6
  41. package/dist/chat/ui/QuoteButton.js +4 -5
  42. package/dist/chat/ui/RunSettings.js +8 -2
  43. package/dist/chat/ui/Scrubber.js +2 -3
  44. package/dist/chat/ui/SubagentControls.d.ts +4 -2
  45. package/dist/chat/ui/SubagentControls.js +13 -11
  46. package/dist/chat/ui/SubagentInfo.d.ts +1 -1
  47. package/dist/chat/ui/SubagentInfo.js +9 -15
  48. package/dist/chat/ui/SubagentStopButton.js +2 -1
  49. package/dist/chat/ui/Timeline.js +39 -21
  50. package/dist/chat/ui/TimelineMenu.js +3 -9
  51. package/dist/chat/ui/UnifiedDiff.js +3 -5
  52. package/dist/chat/ui/VisualFrame.js +3 -4
  53. package/dist/chat/ui/composer/keys.js +19 -11
  54. package/dist/chat/ui/composer/menu-aria.d.ts +10 -0
  55. package/dist/chat/ui/composer/menu-aria.js +18 -0
  56. package/dist/chat/ui/intelligent-ui/render-context.d.ts +1 -1
  57. package/dist/chat/ui/quote-selection.d.ts +5 -2
  58. package/dist/chat/ui/quote-selection.js +3 -2
  59. package/dist/chat/ui/rows/MessageRows.js +26 -8
  60. package/dist/chat/ui/rows/Rows.js +1 -1
  61. package/dist/chat/ui/rows/WorkRows.d.ts +7 -4
  62. package/dist/chat/ui/rows/WorkRows.js +22 -17
  63. package/dist/chat/visual-viewport.d.ts +1 -1
  64. package/dist/chat/visual-viewport.js +9 -8
  65. package/dist/host.d.ts +1 -0
  66. package/dist/host.js +1 -0
  67. package/dist/locales/en/agent-chat.json +30 -7
  68. package/dist/locales/nl/agent-chat.json +30 -7
  69. package/dist/port-transport.js +4 -8
  70. package/dist/state/chats.d.ts +2 -1
  71. package/dist/state/chats.js +29 -15
  72. package/dist/state/usage.js +44 -49
  73. package/dist/storage.js +7 -7
  74. package/dist/theme.css +6 -14
  75. package/dist/usage/LimitsList.js +1 -2
  76. package/dist/usage/UsageBreakdown.js +1 -3
  77. package/dist/usage/UsageDialog.js +2 -4
  78. package/dist/usage/format.js +6 -6
  79. package/dist/usage/money.js +1 -5
  80. package/dist/usage/summary.js +2 -4
  81. package/package.json +24 -4
  82. package/src/agents/model-requirement.ts +36 -0
  83. package/src/agents/status-look.ts +2 -3
  84. package/src/chat/chat-client.ts +7 -3
  85. package/src/chat/drafts.ts +33 -21
  86. package/src/chat/logic/ansi.ts +5 -1
  87. package/src/chat/logic/fork.ts +8 -9
  88. package/src/chat/logic/limit.ts +17 -16
  89. package/src/chat/logic/picker-entries.ts +25 -0
  90. package/src/chat/logic/resume-compaction.ts +2 -11
  91. package/src/chat/logic/scrubber.ts +13 -17
  92. package/src/chat/logic/timeline.ts +39 -13
  93. package/src/chat/logic/tool-catalog.ts +7 -6
  94. package/src/chat/mentions.ts +5 -6
  95. package/src/chat/prompt-hold.ts +27 -0
  96. package/src/chat/quote.ts +20 -0
  97. package/src/chat/runtime-modes.ts +2 -4
  98. package/src/chat/stash.ts +32 -8
  99. package/src/chat/subagent-list.ts +19 -5
  100. package/src/chat/thumbnails.ts +3 -4
  101. package/src/chat/timeline-scroll.ts +58 -53
  102. package/src/chat/ui/BookmarkSubmenu.tsx +1 -5
  103. package/src/chat/ui/ChatActivity.tsx +35 -9
  104. package/src/chat/ui/CodeBlock.tsx +2 -3
  105. package/src/chat/ui/Composer.tsx +263 -138
  106. package/src/chat/ui/ComposerInput.tsx +18 -3
  107. package/src/chat/ui/Markdown.tsx +13 -17
  108. package/src/chat/ui/Pickers.tsx +56 -39
  109. package/src/chat/ui/PromptComposer.tsx +37 -8
  110. package/src/chat/ui/QuoteButton.tsx +5 -5
  111. package/src/chat/ui/RunSettings.tsx +9 -2
  112. package/src/chat/ui/Scrubber.tsx +2 -3
  113. package/src/chat/ui/SubagentControls.tsx +23 -22
  114. package/src/chat/ui/SubagentInfo.tsx +24 -42
  115. package/src/chat/ui/SubagentStopButton.tsx +2 -1
  116. package/src/chat/ui/SubagentTimeline.tsx +1 -2
  117. package/src/chat/ui/Timeline.tsx +44 -24
  118. package/src/chat/ui/TimelineMenu.tsx +3 -9
  119. package/src/chat/ui/UnifiedDiff.tsx +3 -5
  120. package/src/chat/ui/VisualFrame.tsx +3 -4
  121. package/src/chat/ui/composer/keys.ts +21 -11
  122. package/src/chat/ui/composer/menu-aria.ts +29 -0
  123. package/src/chat/ui/intelligent-ui/render-context.ts +2 -2
  124. package/src/chat/ui/quote-selection.ts +6 -4
  125. package/src/chat/ui/rehype-chips.ts +1 -1
  126. package/src/chat/ui/rehype-fade.ts +1 -1
  127. package/src/chat/ui/remark-html-as-text.ts +1 -1
  128. package/src/chat/ui/rows/MessageRows.tsx +47 -9
  129. package/src/chat/ui/rows/Rows.tsx +3 -1
  130. package/src/chat/ui/rows/WorkRows.tsx +101 -70
  131. package/src/chat/visual-viewport.ts +10 -8
  132. package/src/host.ts +4 -3
  133. package/src/locales/en/agent-chat.json +30 -7
  134. package/src/locales/nl/agent-chat.json +30 -7
  135. package/src/port-transport.ts +4 -8
  136. package/src/state/chats.ts +42 -16
  137. package/src/state/usage.ts +49 -60
  138. package/src/storage.ts +7 -7
  139. package/src/theme.css +6 -14
  140. package/src/usage/LimitsList.tsx +2 -4
  141. package/src/usage/UsageBreakdown.tsx +2 -5
  142. package/src/usage/UsageDialog.tsx +2 -4
  143. package/src/usage/format.ts +6 -6
  144. package/src/usage/money.ts +1 -5
  145. package/src/usage/summary.ts +2 -4
@@ -4,6 +4,7 @@ import type {
4
4
  ChatAttachResult,
5
5
  ChatBookmark,
6
6
  ChatPreferencesPayload,
7
+ ChatQuoteSource,
7
8
  ChatSkill,
8
9
  ChatVisual,
9
10
  ModelSelection,
@@ -35,6 +36,8 @@ export interface ChatSendExtras {
35
36
  skills?: string[];
36
37
  /* Chats of the project picked with `@`; only their ids travel, the agent reads them itself. */
37
38
  chats?: string[];
39
+ /* The reply a quote at the head of the text was taken from. */
40
+ quoteSource?: ChatQuoteSource;
38
41
  attachments?: ChatAttachmentUpload[];
39
42
  }
40
43
 
@@ -63,6 +66,7 @@ export class ChatClient {
63
66
  private readonly mounted = new MountedRegistry<Mounted>();
64
67
  private readonly unsubscribe: Array<() => void> = [];
65
68
  private readonly earlier = new Map<string, Promise<void>>();
69
+ private readonly inspections = new Map<string, Promise<ChatAttachResult>>();
66
70
  private preferences: ChatPreferencesPayload | null = null;
67
71
 
68
72
  constructor(transport: ChatTransport, sink: ChatSink, providers: ProviderSink | null = null) {
@@ -89,8 +93,6 @@ export class ChatClient {
89
93
  }
90
94
  }
91
95
 
92
- private readonly inspections = new Map<string, Promise<ChatAttachResult>>();
93
-
94
96
  inspect(chatId: string): Promise<ChatAttachResult> {
95
97
  const pending = this.inspections.get(chatId);
96
98
  if (pending) {
@@ -171,9 +173,11 @@ export class ChatClient {
171
173
  return this.transport.request('chat.send', {
172
174
  chatId,
173
175
  text,
176
+ delivery: extras.delivery,
174
177
  mentions: extras.mentions,
175
178
  skills: extras.skills,
176
179
  chats: extras.chats,
180
+ quoteSource: extras.quoteSource,
177
181
  attachments: extras.attachments
178
182
  });
179
183
  }
@@ -347,7 +351,7 @@ export class ChatClient {
347
351
  this.sendPreferences();
348
352
  void this.loadProviders();
349
353
  void this.loadStatuses();
350
- void this.mounted.reattachAll((chatId) => this.attach(chatId).then(() => undefined));
354
+ void this.mounted.reattachAll((chatId) => this.attach(chatId));
351
355
  return;
352
356
  }
353
357
  this.mounted.detachAll();
@@ -14,12 +14,22 @@ export interface ChatDraft {
14
14
  attachments: ChatAttachmentUpload[];
15
15
  /* A piece of an answer that goes above the text as a blockquote; empty for none. */
16
16
  quote: string;
17
+ /* The reply the quote was taken from; null when the quote names none. */
18
+ quoteSource: string | null;
17
19
  }
18
20
 
19
- export const EMPTY_DRAFT: ChatDraft = { text: '', mentions: [], skills: [], chats: [], attachments: [], quote: '' };
21
+ export const EMPTY_DRAFT: ChatDraft = { text: '', mentions: [], skills: [], chats: [], attachments: [], quote: '', quoteSource: null };
20
22
 
21
23
  // Older records only had text; the rest is filled in on read.
22
- type DraftRecord = { text: string; mentions?: string[]; skills?: string[]; chats?: string[]; attachments?: ChatAttachmentUpload[]; quote?: string };
24
+ type DraftRecord = {
25
+ text: string;
26
+ mentions?: string[];
27
+ skills?: string[];
28
+ chats?: string[];
29
+ attachments?: ChatAttachmentUpload[];
30
+ quote?: string;
31
+ quoteSource?: string;
32
+ };
23
33
 
24
34
  const storage = persistedJson<Record<string, DraftRecord>>(
25
35
  storageKey,
@@ -28,14 +38,6 @@ const storage = persistedJson<Record<string, DraftRecord>>(
28
38
  () => chatStorageLegacyKeys('drafts')
29
39
  );
30
40
 
31
- function readAll(): Record<string, DraftRecord> {
32
- return storage.read();
33
- }
34
-
35
- function store(drafts: Record<string, DraftRecord>): boolean {
36
- return storage.write(drafts);
37
- }
38
-
39
41
  export function isEmptyDraft(draft: ChatDraft): boolean {
40
42
  return draft.text.trim() === '' && draft.attachments.length === 0 && draft.quote === '';
41
43
  }
@@ -46,7 +48,7 @@ export function isEmptyDraft(draft: ChatDraft): boolean {
46
48
  */
47
49
  export const useDrafts = createChatStore<{ ids: string[] }>(
48
50
  () => ({ ids: [] }),
49
- (store) => store.setState({ ids: Object.keys(readAll()) })
51
+ (store) => store.setState({ ids: Object.keys(storage.read()) })
50
52
  );
51
53
 
52
54
  export function useHasDraft(chatId: string): boolean {
@@ -63,7 +65,7 @@ function trackDraft(chatId: string, held: boolean): void {
63
65
 
64
66
  /* An unsent prompt per chat node, kept across reloads so a half-written message is never lost. */
65
67
  export function readDraft(chatId: string): ChatDraft {
66
- const record = readAll()[chatId];
68
+ const record = storage.read()[chatId];
67
69
  return record
68
70
  ? {
69
71
  text: record.text,
@@ -71,17 +73,18 @@ export function readDraft(chatId: string): ChatDraft {
71
73
  skills: record.skills ?? [],
72
74
  chats: record.chats ?? [],
73
75
  attachments: record.attachments ?? [],
74
- quote: record.quote ?? ''
76
+ quote: record.quote ?? '',
77
+ quoteSource: record.quoteSource ?? null
75
78
  }
76
79
  : EMPTY_DRAFT;
77
80
  }
78
81
 
79
82
  export function writeDraft(chatId: string, draft: ChatDraft): void {
80
- const drafts = readAll();
83
+ const drafts = storage.read();
81
84
  trackDraft(chatId, !isEmptyDraft(draft));
82
85
  if (isEmptyDraft(draft)) {
83
86
  delete drafts[chatId];
84
- store(drafts);
87
+ storage.write(drafts);
85
88
  return;
86
89
  }
87
90
  drafts[chatId] = {
@@ -90,14 +93,22 @@ export function writeDraft(chatId: string, draft: ChatDraft): void {
90
93
  skills: draft.skills,
91
94
  chats: draft.chats,
92
95
  attachments: draft.attachments,
93
- quote: draft.quote
96
+ quote: draft.quote,
97
+ ...(draft.quoteSource === null ? {} : { quoteSource: draft.quoteSource })
94
98
  };
95
- if (store(drafts)) {
99
+ if (storage.write(drafts)) {
96
100
  return;
97
101
  }
98
102
  // A file can outgrow the storage quota; the text is the part worth keeping then.
99
- drafts[chatId] = { text: draft.text, mentions: draft.mentions, skills: draft.skills, chats: draft.chats, quote: draft.quote };
100
- store(drafts);
103
+ drafts[chatId] = {
104
+ text: draft.text,
105
+ mentions: draft.mentions,
106
+ skills: draft.skills,
107
+ chats: draft.chats,
108
+ quote: draft.quote,
109
+ ...(draft.quoteSource === null ? {} : { quoteSource: draft.quoteSource })
110
+ };
111
+ storage.write(drafts);
101
112
  }
102
113
 
103
114
  /* Text handed to a draft from outside the composer goes under what was already typed, never over it. */
@@ -116,7 +127,7 @@ function union(first: readonly string[], second: readonly string[]): string[] {
116
127
  */
117
128
  export function takeBackIntoDraft(
118
129
  current: ChatDraft,
119
- taken: Omit<ChatDraft, 'quote'>
130
+ taken: Omit<ChatDraft, 'quote' | 'quoteSource'>
120
131
  ): { draft: ChatDraft; rejected: Array<{ name: string; reason: string }> } {
121
132
  const checked = checkAttachmentLimits(
122
133
  current.attachments.length,
@@ -130,7 +141,8 @@ export function takeBackIntoDraft(
130
141
  skills: union(taken.skills, current.skills),
131
142
  chats: union(taken.chats, current.chats),
132
143
  attachments: [...checked.accepted.map((entry) => entry.upload), ...current.attachments],
133
- quote: current.quote
144
+ quote: current.quote,
145
+ quoteSource: current.quoteSource
134
146
  },
135
147
  rejected: checked.rejected
136
148
  };
@@ -164,7 +164,7 @@ function styleOf(state: AnsiState): CSSProperties {
164
164
  return style;
165
165
  }
166
166
 
167
- export function parseAnsi(text: string, options: { limit?: number; tailLines?: number } = {}): { tokens: AnsiToken[]; omitted: number } {
167
+ function styledTokens(text: string): AnsiToken[] {
168
168
  const tokens: AnsiToken[] = [];
169
169
  let state: AnsiState = {};
170
170
  let position = 0;
@@ -180,7 +180,11 @@ export function parseAnsi(text: string, options: { limit?: number; tailLines?: n
180
180
  if (position < text.length) {
181
181
  tokens.push({ content: text.slice(position), style: styleOf(state) });
182
182
  }
183
+ return tokens;
184
+ }
183
185
 
186
+ export function parseAnsi(text: string, options: { limit?: number; tailLines?: number } = {}): { tokens: AnsiToken[]; omitted: number } {
187
+ const tokens = styledTokens(text);
184
188
  const plain = tokens.map((token) => token.content).join('');
185
189
  let start = 0;
186
190
  let end = plain.length;
@@ -172,20 +172,19 @@ export function forkPayload(input: {
172
172
  worktree?: ForkWorktreeChoice | null;
173
173
  cli?: { original: ForkCliChoice; chosen: ForkCliChoice };
174
174
  }): ChatForkPayload {
175
+ const { cli, worktree } = input;
176
+ const provider = cli !== undefined && cli.chosen.provider !== cli.original.provider ? cli.chosen.provider : null;
177
+ const selection = cli !== undefined && (provider !== null || cli.chosen.selection.model !== cli.original.selection.model) ? cli.chosen.selection : null;
178
+ const account = cli?.chosen.account !== undefined && (provider !== null || cli.chosen.account !== cli.original.account) ? cli.chosen.account : null;
175
179
  return {
176
180
  chatId: input.chatId,
177
181
  turnId: input.turnId,
178
182
  title: input.title,
179
183
  ...(input.shape === 'view' ? { asView: true } : {}),
180
- ...(input.cli && input.cli.chosen.provider !== input.cli.original.provider ? { provider: input.cli.chosen.provider } : {}),
181
- ...(input.cli && (input.cli.chosen.provider !== input.cli.original.provider || input.cli.chosen.selection.model !== input.cli.original.selection.model)
182
- ? { selection: input.cli.chosen.selection }
183
- : {}),
184
- ...(input.cli?.chosen.account !== undefined &&
185
- (input.cli.chosen.provider !== input.cli.original.provider || input.cli.chosen.account !== input.cli.original.account)
186
- ? { account: input.cli.chosen.account }
187
- : {}),
188
- ...(input.worktree ? { worktree: { branch: input.worktree.branch }, ...(input.worktree.filesAfterTurn ? { filesAfterTurn: true } : {}) } : {})
184
+ ...(provider === null ? {} : { provider }),
185
+ ...(selection === null ? {} : { selection }),
186
+ ...(account === null ? {} : { account }),
187
+ ...(worktree ? { worktree: { branch: worktree.branch }, ...(worktree.filesAfterTurn ? { filesAfterTurn: true } : {}) } : {})
189
188
  };
190
189
  }
191
190
 
@@ -27,20 +27,21 @@ export function limitView(info: Pick<ChatInfo, 'limit' | 'resumeAt' | 'activeTur
27
27
  detail: resumes === null ? i18next.t('agent-chat:limit.overload.sendAgain') : i18next.t('agent-chat:limit.overload.retries', { time: resumes })
28
28
  };
29
29
  }
30
- const resets = limit.resetsAt === undefined ? null : formatMoment(limit.resetsAt, now);
31
- return {
32
- pill:
33
- resumes !== null
34
- ? i18next.t('agent-chat:limit.usage.pillResumes', { time: resumes })
35
- : resets !== null
36
- ? i18next.t('agent-chat:limit.usage.pillUntil', { time: resets })
37
- : i18next.t('agent-chat:limit.usage.pill'),
38
- title: i18next.t('agent-chat:limit.usage.title'),
39
- detail:
40
- resumes !== null
41
- ? i18next.t('agent-chat:limit.usage.resumes', { time: resumes })
42
- : resets !== null
43
- ? i18next.t('agent-chat:limit.usage.resets', { time: resets })
44
- : null
45
- };
30
+ const title = i18next.t('agent-chat:limit.usage.title');
31
+ if (resumes !== null) {
32
+ return {
33
+ pill: i18next.t('agent-chat:limit.usage.pillResumes', { time: resumes }),
34
+ title,
35
+ detail: i18next.t('agent-chat:limit.usage.resumes', { time: resumes })
36
+ };
37
+ }
38
+ if (limit.resetsAt !== undefined) {
39
+ const resets = formatMoment(limit.resetsAt, now);
40
+ return {
41
+ pill: i18next.t('agent-chat:limit.usage.pillUntil', { time: resets }),
42
+ title,
43
+ detail: i18next.t('agent-chat:limit.usage.resets', { time: resets })
44
+ };
45
+ }
46
+ return { pill: i18next.t('agent-chat:limit.usage.pill'), title, detail: null };
46
47
  }
@@ -0,0 +1,25 @@
1
+ import type { ModelInfo, ProviderInfo } from '@adecore/agent-contracts';
2
+
3
+ /* A model row, or the one row that folds the legacy models open. Both take a turn in the arrow keys. */
4
+ export type PickerEntry = { kind: 'model'; provider: ProviderInfo; model: ModelInfo } | { kind: 'legacy'; count: number };
5
+
6
+ function matches(model: ModelInfo, query: string): boolean {
7
+ return model.name.toLowerCase().includes(query) || model.slug.toLowerCase().includes(query);
8
+ }
9
+
10
+ /*
11
+ * The rows of the model list. A model an old CLI cannot run is a row like any other, so a search for
12
+ * only that model still finds the row that explains why it cannot be picked.
13
+ */
14
+ export function pickerEntriesOf(providers: readonly ProviderInfo[], query: string, legacyOpen: boolean): PickerEntry[] {
15
+ const rows = providers.flatMap((entry) => entry.models.map((model) => ({ kind: 'model' as const, provider: entry, model })));
16
+ if (query !== '') {
17
+ return rows.filter((row) => matches(row.model, query));
18
+ }
19
+ const live = rows.filter((row) => !row.model.legacy);
20
+ const legacy = rows.filter((row) => row.model.legacy);
21
+ if (legacy.length === 0) {
22
+ return live;
23
+ }
24
+ return legacyOpen ? [...live, { kind: 'legacy', count: legacy.length }, ...legacy] : [...live, { kind: 'legacy', count: legacy.length }];
25
+ }
@@ -16,19 +16,10 @@ export interface ResumeCompactionOffer {
16
16
  }
17
17
 
18
18
  function lastTurn(items: readonly ChatItem[]): Extract<ChatItem, { kind: 'turn' }> | null {
19
- for (let i = items.length - 1; i >= 0; i--) {
20
- const item = items[i]!;
21
- if (item.kind === 'turn') {
22
- return item;
23
- }
24
- }
25
- return null;
19
+ return items.findLast((item): item is Extract<ChatItem, { kind: 'turn' }> => item.kind === 'turn') ?? null;
26
20
  }
27
21
 
28
- /*
29
- * Whether to offer compacting before the next message. The caller passes `now`, so this stays pure
30
- * and the test needs no clock.
31
- */
22
+ /* Whether to offer compacting before the next message. */
32
23
  export function resumeCompactionOffer({
33
24
  info,
34
25
  compaction,
@@ -127,19 +127,10 @@ export function tickOfRow(ticks: readonly ScrubberTick[], rowIndex: number): num
127
127
  if (ticks.length === 0) {
128
128
  return null;
129
129
  }
130
- let low = 0;
131
- let high = ticks.length - 1;
132
- let found = 0;
133
- while (low <= high) {
134
- const middle = (low + high) >> 1;
135
- if (ticks[middle]!.rowIndex <= rowIndex) {
136
- found = middle;
137
- low = middle + 1;
138
- } else {
139
- high = middle - 1;
140
- }
141
- }
142
- return found;
130
+ return Math.max(
131
+ 0,
132
+ lastIndexAtOrBelow(ticks.length, (index) => ticks[index]!.rowIndex, rowIndex)
133
+ );
143
134
  }
144
135
 
145
136
  /* Per tick, whether any of the rows has a find hit in its stretch. */
@@ -199,14 +190,14 @@ export function tickWidth(kind: TickKind, distanceFromPointer: number | null): n
199
190
  return kind === 'wake' ? WIDTH_WAKE_PX : WIDTH_PERSON_PX;
200
191
  }
201
192
 
202
- /* The last index whose start lies at or above a line, or -1. The starts only grow. */
203
- function lastAtOrAbove(starts: readonly number[], line: number): number {
193
+ /* The last index whose value is at most `limit`, or -1. The values only grow. */
194
+ function lastIndexAtOrBelow(length: number, valueAt: (index: number) => number, limit: number): number {
204
195
  let low = 0;
205
- let high = starts.length - 1;
196
+ let high = length - 1;
206
197
  let found = -1;
207
198
  while (low <= high) {
208
199
  const middle = (low + high) >> 1;
209
- if (starts[middle]! <= line) {
200
+ if (valueAt(middle) <= limit) {
210
201
  found = middle;
211
202
  low = middle + 1;
212
203
  } else {
@@ -216,6 +207,11 @@ function lastAtOrAbove(starts: readonly number[], line: number): number {
216
207
  return found;
217
208
  }
218
209
 
210
+ /* The last index whose start lies at or above a line, or -1. */
211
+ function lastAtOrAbove(starts: readonly number[], line: number): number {
212
+ return lastIndexAtOrBelow(starts.length, (index) => starts[index]!, line);
213
+ }
214
+
219
215
  export interface ReadingPosition {
220
216
  /* Where each message's row starts in the thread, in the virtualizer's measurements. */
221
217
  starts: readonly number[];
@@ -52,7 +52,7 @@ export type TimelineRow =
52
52
  | { kind: 'question'; id: string; item: ChatQuestionItem }
53
53
  | { kind: 'note'; id: string; level: 'info' | 'warning' | 'error'; text: string; from?: string }
54
54
  | { kind: 'compaction'; id: string; preTokens: number | null }
55
- | { kind: 'changed-files'; id: string; turnId: string; tools: ChatToolItem[]; diff: ChatCheckpointDiff | null; checkpoint: boolean }
55
+ | { kind: 'changed-files'; id: string; turnId: string; tools: ChatToolItem[]; diff: ChatCheckpointDiff | null; checkpoint: boolean; baseMoved?: BaseMoved }
56
56
  | { kind: 'turn-fold'; id: string; turn: ChatTurnItem; label: string; work: string[]; hiddenCount: number; expanded: boolean }
57
57
  | { kind: 'forks'; id: string; turnId: string }
58
58
  | { kind: 'working'; id: string; startedAt: number }
@@ -88,10 +88,9 @@ export function summarizeGroup(tools: ChatToolItem[]): string {
88
88
  if (only !== null && toolEntry(only)?.grouped === true) {
89
89
  return i18next.t(`agent-chat:group.tools.${only}`, { count: tools.length });
90
90
  }
91
- /* A run that only touches files says so once, whether it took one tool or three, and counts the
92
- files rather than the calls: three edits to one file are one file edited. */
91
+ // Counts the files rather than the calls: three edits to one file are one file edited.
93
92
  if (tools.every((tool) => isFileChange(tool.name))) {
94
- const paths = new Set(tools.map((tool) => (tool.input as { file_path?: string })?.file_path ?? tool.id));
93
+ const paths = new Set(tools.map(editedPath));
95
94
  return i18next.t('agent-chat:group.edited', { count: paths.size });
96
95
  }
97
96
  if (only !== null) {
@@ -100,6 +99,10 @@ export function summarizeGroup(tools: ChatToolItem[]): string {
100
99
  return i18next.t('agent-chat:group.toolCalls', { count: tools.length });
101
100
  }
102
101
 
102
+ function editedPath(tool: ChatToolItem): string {
103
+ return (tool.input as { file_path?: string })?.file_path ?? tool.id;
104
+ }
105
+
103
106
  /*
104
107
  * What a folded turn did, a sentence per kind of call in the order it first came: "Read 11 files",
105
108
  * "Searched 2 patterns". Every file change reads as one sentence, and calls without a sentence of
@@ -112,7 +115,7 @@ export function summarizeTurn(tools: readonly ChatToolItem[]): string[] {
112
115
  let other = 0;
113
116
  for (const tool of tools) {
114
117
  if (isFileChange(tool.name)) {
115
- edited.add((tool.input as { file_path?: string })?.file_path ?? tool.id);
118
+ edited.add(editedPath(tool));
116
119
  counts.set('agent-chat:group.edited', edited.size);
117
120
  } else if (toolEntry(tool.name)?.grouped === true) {
118
121
  const key = `agent-chat:group.tools.${tool.name}`;
@@ -319,6 +322,12 @@ function rowsForItems(items: ChatItem[], options: TimelineOptions, children: Map
319
322
  return rows;
320
323
  }
321
324
 
325
+ /* The branch the turn started on was not the one it ended on, so its diff also holds what came in with the move. */
326
+ export interface BaseMoved {
327
+ from: string;
328
+ to: string;
329
+ }
330
+
322
331
  /* The card of a settled turn: the checkpoint diff when the host took one, else what the tool calls carry. */
323
332
  function changedFilesRow(turn: ChatTurnItem, items: ChatItem[]): TimelineRow | null {
324
333
  const edits = items.filter(
@@ -329,7 +338,16 @@ function changedFilesRow(turn: ChatTurnItem, items: ChatItem[]): TimelineRow | n
329
338
  if (empty) {
330
339
  return null;
331
340
  }
332
- return { kind: 'changed-files', id: `files-${turn.id}`, turnId: turn.id, tools: edits, diff, checkpoint: turn.checkpoint !== undefined };
341
+ const moved = turn.checkpointHead !== undefined && turn.checkpointHeadAfter !== undefined && turn.checkpointHead !== turn.checkpointHeadAfter;
342
+ return {
343
+ kind: 'changed-files',
344
+ id: `files-${turn.id}`,
345
+ turnId: turn.id,
346
+ tools: edits,
347
+ diff,
348
+ checkpoint: turn.checkpoint !== undefined,
349
+ ...(moved ? { baseMoved: { from: turn.checkpointHead!, to: turn.checkpointHeadAfter! } } : {})
350
+ };
333
351
  }
334
352
 
335
353
  function lastAssistantRow(rows: TimelineRow[]): TimelineRow | null {
@@ -345,12 +363,14 @@ function lastAssistantRow(rows: TimelineRow[]): TimelineRow | null {
345
363
  return null;
346
364
  }
347
365
 
348
- /* The whole thread as rows; items without a turn (older records) are shown as they are. */
349
- export function deriveTimelineRows(items: ChatItem[], options: TimelineOptions): TimelineRow[] {
350
- const rows: TimelineRow[] = [];
351
- const children = groupChildren(items);
352
- // Items are grouped by turn, in order of first appearance; runs of turnless items keep their place.
353
- const chunks: Array<{ turnId: string | null; items: ChatItem[] }> = [];
366
+ interface TurnChunk {
367
+ turnId: string | null;
368
+ items: ChatItem[];
369
+ }
370
+
371
+ /* Items grouped by turn, in order of first appearance; runs of turnless items keep their place. */
372
+ function chunksByTurn(items: ChatItem[]): TurnChunk[] {
373
+ const chunks: TurnChunk[] = [];
354
374
  const byTurn = new Map<string, ChatItem[]>();
355
375
  for (const item of items) {
356
376
  if (item.turnId === null) {
@@ -370,8 +390,14 @@ export function deriveTimelineRows(items: ChatItem[], options: TimelineOptions):
370
390
  }
371
391
  bucket.push(item);
372
392
  }
393
+ return chunks;
394
+ }
373
395
 
374
- for (const chunk of chunks) {
396
+ /* The whole thread as rows; items without a turn (older records) are shown as they are. */
397
+ export function deriveTimelineRows(items: ChatItem[], options: TimelineOptions): TimelineRow[] {
398
+ const rows: TimelineRow[] = [];
399
+ const children = groupChildren(items);
400
+ for (const chunk of chunksByTurn(items)) {
375
401
  if (chunk.turnId === null) {
376
402
  rows.push(...rowsForItems(chunk.items, options, children));
377
403
  continue;
@@ -6,8 +6,10 @@ export interface ToolEntry {
6
6
  icon: LucideIcon;
7
7
  /* The input keys that hold the one line saying what the call is about, best first. */
8
8
  summary: readonly string[];
9
- /* Whether a run of calls of this name reads as a sentence of its own (`agent-chat:group.tools.<name>`).
10
- One that changes files without a sentence falls to `agent-chat:group.edited`, which counts files rather than calls. */
9
+ /*
10
+ * Whether a run of calls of this name reads as a sentence of its own (`agent-chat:group.tools.<name>`).
11
+ * One that changes files without a sentence falls to `agent-chat:group.edited`, which counts files.
12
+ */
11
13
  grouped: boolean;
12
14
  /* Whether the call writes to a file, which is what a mixed run of calls is named after. */
13
15
  changesFiles: boolean;
@@ -27,10 +29,9 @@ function entry(icon: LucideIcon, summary: readonly string[], rest: Partial<ToolE
27
29
  }
28
30
 
29
31
  /*
30
- * The icon, the summary line, the group sentence and the file-changing flag of every tool the thread
31
- * has a word for, in one table, because they used to sit in four and a name added to one of them was
32
- * missing from the rest. A name that is not here still draws, reads and groups; it falls back to the
33
- * wrench and to the first string of its input.
32
+ * Every tool the thread has a word for, in one table so a new name is never missing from a lookup. A
33
+ * name that is not here still draws, reads and groups; it falls back to the wrench and to the first
34
+ * string of its input.
34
35
  */
35
36
  export const TOOL_CATALOG: Record<string, ToolEntry> = {
36
37
  Read: entry(Eye, ['file_path'], { readsImage: true }),
@@ -1,5 +1,7 @@
1
- /* What a file dragged out of an app's own list of files carries: the paths, space separated, relative
2
- to the folder. The composer takes it next to the image files it already accepts. */
1
+ /*
2
+ * What a file dragged out of an app's own list of files carries: the paths, space separated, relative
3
+ * to the folder. The composer takes it next to the image files it already accepts.
4
+ */
3
5
  export const MENTION_DRAG_TYPE = 'application/x-adecore-mention';
4
6
  export const LEGACY_MENTION_DRAG_TYPE = 'application/x-ruimte-mention';
5
7
  export const MENTION_DRAG_TYPES = [MENTION_DRAG_TYPE, LEGACY_MENTION_DRAG_TYPE] as const;
@@ -145,10 +147,7 @@ export function presentSkills(text: string, chosen: string[]): string[] {
145
147
  return present(text, '$', chosen);
146
148
  }
147
149
 
148
- /*
149
- * The characters a segment stands for, sigil included. Joined, the segments spell the text they
150
- * were cut from.
151
- */
150
+ /* The characters a segment stands for, sigil included; joined, the segments spell the text they were cut from. */
152
151
  export function chipText(segment: ChipSegment): string {
153
152
  if (segment.kind === 'mention') {
154
153
  return `@${segment.path}`;
@@ -0,0 +1,27 @@
1
+ /* How long a person must stop typing before a waiting prompt card takes the composer's place. */
2
+ export const PROMPT_HOLD_PAUSE_MS = 2000;
3
+
4
+ export interface HoldInput {
5
+ /* When the last change to the draft was made; null when none was since the hold last lifted. */
6
+ typedAt: number | null;
7
+ /* An IME composition is under way, which can run for any length without a change. */
8
+ composing: boolean;
9
+ }
10
+
11
+ export type HoldRelease = 'pause' | 'send' | 'blur' | 'show';
12
+
13
+ /* A card waits while the person is composing, or typed less than the pause ago. */
14
+ export function holdsPrompt(input: HoldInput, now: number): boolean {
15
+ if (input.composing) {
16
+ return true;
17
+ }
18
+ return input.typedAt !== null && now - input.typedAt < PROMPT_HOLD_PAUSE_MS;
19
+ }
20
+
21
+ /* The milliseconds until the hold lifts by itself; null when it holds until something else lifts it, or does not hold. */
22
+ export function holdLiftsIn(input: HoldInput, now: number): number | null {
23
+ if (input.composing || input.typedAt === null) {
24
+ return null;
25
+ }
26
+ return Math.max(0, input.typedAt + PROMPT_HOLD_PAUSE_MS - now);
27
+ }
package/src/chat/quote.ts CHANGED
@@ -24,3 +24,23 @@ export function withQuote(quote: string, message: string): string {
24
24
  .join('\n');
25
25
  return message === '' ? block : `${block}\n\n${message}`;
26
26
  }
27
+
28
+ /*
29
+ * The inverse of `withQuote`: the quote a message opens with and what follows it. Null when the text
30
+ * does not open with a blockquote that ends on a blank line or the end of the text.
31
+ */
32
+ export function splitQuote(text: string): { quote: string; rest: string } | null {
33
+ const lines = text.split('\n');
34
+ let end = 0;
35
+ while (end < lines.length && (lines[end] === '>' || lines[end]!.startsWith('> '))) {
36
+ end += 1;
37
+ }
38
+ if (end === 0 || (end < lines.length && lines[end] !== '')) {
39
+ return null;
40
+ }
41
+ const quote = lines
42
+ .slice(0, end)
43
+ .map((line) => line.slice(2))
44
+ .join('\n');
45
+ return { quote, rest: lines.slice(end + 1).join('\n') };
46
+ }
@@ -2,10 +2,8 @@ import i18next from 'i18next';
2
2
  import { RuntimeModeSchema, type RuntimeMode } from '@adecore/agent-contracts';
3
3
 
4
4
  /*
5
- * The runtime modes in the order they are offered, shared by the composer's mode picker, the
6
- * settings dialog and the agent menus. That order is the schema's, which the host reads as the
7
- * rank from narrowest to widest. Only the ids live here: the words are in `modes.<id>` of the `chat`
8
- * namespace, read when a surface draws one rather than when this module loads.
5
+ * The runtime modes in the order they are offered: the schema's, which the host reads as the rank
6
+ * from narrowest to widest. Their words are read when a surface draws one, not when this module loads.
9
7
  */
10
8
  export const RUNTIME_MODES: readonly RuntimeMode[] = RuntimeModeSchema.options;
11
9