u-foo 3.0.1 → 3.0.2

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.
@@ -2,7 +2,9 @@
2
2
 
3
3
  const { renderMarkdownLinesAnsi } = require("../format/markdownRenderer");
4
4
 
5
- const MARKDOWN_BODY_KINDS = new Set(["assistant", "agent", "plain", "error", "success"]);
5
+ // Match ucode: markdown only for conversational prose. System/user rows stay
6
+ // plain so app-generated prefixes (›, ✓, Error:) keep Ink color control.
7
+ const MARKDOWN_BODY_KINDS = new Set(["assistant", "agent", "report"]);
6
8
 
7
9
  function stripBlessedTags(text = "") {
8
10
  return String(text || "")
@@ -30,6 +32,10 @@ function compactDividerLabel(text = "") {
30
32
  return label || String(text || "").trim() || "section";
31
33
  }
32
34
 
35
+ function stripUserPromptPrefix(text = "") {
36
+ return String(text || "").replace(/^›\s*/, "");
37
+ }
38
+
33
39
  function splitSpeakerBody(raw = "") {
34
40
  const source = String(raw || "");
35
41
  const dotIdx = source.indexOf(" · ");
@@ -48,13 +54,16 @@ function splitSpeakerBody(raw = "") {
48
54
 
49
55
  function formatChatLogBody(body = "", kind = "plain", markdownState = null) {
50
56
  const text = String(body || "");
51
- if (!MARKDOWN_BODY_KINDS.has(kind)) return text;
57
+ const state = markdownState && typeof markdownState === "object"
58
+ ? markdownState
59
+ : { inCodeBlock: false };
60
+ // Continuations inside an open fence stay plain-kind but still need the
61
+ // shared ANSI renderer so the fence closes cleanly (same as ucode).
62
+ const allowMarkdown = MARKDOWN_BODY_KINDS.has(kind) || state.inCodeBlock === true;
63
+ if (!allowMarkdown) return text;
52
64
  // Structural markers / empty rows stay untouched.
53
- if (!text.trim()) return text;
65
+ if (!text.trim() && !state.inCodeBlock) return text;
54
66
  try {
55
- const state = markdownState && typeof markdownState === "object"
56
- ? markdownState
57
- : { inCodeBlock: false };
58
67
  const lines = renderMarkdownLinesAnsi(text, state);
59
68
  if (!Array.isArray(lines) || lines.length === 0) return text;
60
69
  return lines.length === 1 ? lines[0] : lines.join("\n");
@@ -95,6 +104,15 @@ function classifyChatLogLine(text = "") {
95
104
  body: rawBody || clean.replace(/^[✓✔]\s*/, ""),
96
105
  };
97
106
  }
107
+ // ucode-style user prompt already embedded in text
108
+ if (/^›\s/.test(raw) || raw === "›") {
109
+ return {
110
+ kind: "user",
111
+ marker: "›",
112
+ speaker: "",
113
+ body: stripUserPromptPrefix(raw) || " ",
114
+ };
115
+ }
98
116
  const cleanDot = clean.match(/^([^·\n]{1,64})\s+·\s+(.*)$/);
99
117
  if (cleanDot) {
100
118
  const parts = splitSpeakerBody(raw);
@@ -103,7 +121,7 @@ function classifyChatLogLine(text = "") {
103
121
  const kind = lower === "ufoo" ? "assistant" : "agent";
104
122
  return {
105
123
  kind,
106
- marker: kind === "assistant" ? "◆" : "",
124
+ marker: kind === "assistant" ? "◆" : "",
107
125
  speaker,
108
126
  body: parts.body != null ? parts.body : (cleanDot[2] || " "),
109
127
  };
@@ -113,7 +131,7 @@ function classifyChatLogLine(text = "") {
113
131
  const parts = splitSpeakerBody(raw);
114
132
  return {
115
133
  kind: "agent",
116
- marker: "",
134
+ marker: "",
117
135
  speaker: stripMarkdownDecorators(parts.speaker || cleanColon[1]).trim(),
118
136
  body: parts.body != null ? parts.body : (cleanColon[2] || " "),
119
137
  };
@@ -121,30 +139,178 @@ function classifyChatLogLine(text = "") {
121
139
  return { kind: "plain", marker: "", speaker: "", body: raw || clean };
122
140
  }
123
141
 
142
+ /**
143
+ * Map router/history sourceType onto a display row. Heuristic classification
144
+ * still runs first so speaker/body parsing stays shared; sourceType only
145
+ * upgrades or specializes the visual role (user / system / report / …).
146
+ */
147
+ function applySourceTypeToRow(row, sourceType = "", meta = {}) {
148
+ const base = row && typeof row === "object" ? { ...row } : classifyChatLogLine("");
149
+ const type = String(sourceType || "").toLowerCase();
150
+ const event = String((meta && (meta.event || meta.Event)) || "").toLowerCase();
151
+ const isReport = type === "report" || event === "controller_report";
152
+
153
+ if (type === "user") {
154
+ if (base.kind === "spacer") {
155
+ return {
156
+ ...base,
157
+ kind: "user",
158
+ marker: " ",
159
+ speaker: "",
160
+ body: " ",
161
+ };
162
+ }
163
+ // Indented continuations of a multi-line user echo stay user-colored
164
+ // without a second › marker.
165
+ if (base.kind === "plain" && /^\s{2,}\S/.test(String(base.body || ""))) {
166
+ return {
167
+ ...base,
168
+ kind: "user",
169
+ marker: " ",
170
+ speaker: "",
171
+ body: base.body || " ",
172
+ };
173
+ }
174
+ return {
175
+ ...base,
176
+ kind: "user",
177
+ marker: "›",
178
+ speaker: "",
179
+ body: stripUserPromptPrefix(base.body || ""),
180
+ };
181
+ }
182
+ if (type === "error") {
183
+ if (base.kind === "plain" || base.kind === "spacer") return base;
184
+ return {
185
+ ...base,
186
+ kind: "error",
187
+ marker: base.marker === "!" ? base.marker : "!",
188
+ speaker: base.speaker || "error",
189
+ };
190
+ }
191
+ if (isReport) {
192
+ // Keep indented continuations foldable under the report head.
193
+ if (base.kind === "plain" || base.kind === "spacer") return base;
194
+ const speaker = base.speaker || "report";
195
+ return {
196
+ ...base,
197
+ kind: "report",
198
+ marker: "▣",
199
+ speaker,
200
+ body: base.body || " ",
201
+ };
202
+ }
203
+ if (type === "bus") {
204
+ if (base.kind === "plain" || base.kind === "spacer" || base.kind === "success" || base.kind === "error") {
205
+ return base;
206
+ }
207
+ if (base.kind === "assistant") return base;
208
+ return {
209
+ ...base,
210
+ kind: "agent",
211
+ marker: base.marker === "◆" ? "◆" : "◇",
212
+ speaker: base.speaker || "bus",
213
+ };
214
+ }
215
+ if (type === "reply") {
216
+ if (base.kind === "plain" || base.kind === "spacer") return base;
217
+ return {
218
+ ...base,
219
+ kind: "assistant",
220
+ marker: "◆",
221
+ speaker: base.speaker || "ufoo",
222
+ };
223
+ }
224
+ if (type === "system" || type === "disambiguate") {
225
+ // Keep success/error markers when the line already carries them.
226
+ if (base.kind === "success" || base.kind === "error" || base.kind === "divider" || base.kind === "banner") {
227
+ return base;
228
+ }
229
+ if (base.kind === "spacer") return base;
230
+
231
+ const body = String(base.body || "");
232
+ // Config / list detail rows already ship their own " • …" prefix from
233
+ // commandExecutor. Keep them plain so they fold under the ✓ header and
234
+ // don't pick up a competing tiny "·" gutter glyph.
235
+ if (/^\s*[•·\-*]/.test(body) || /^\s{2,}\S/.test(body)) {
236
+ return {
237
+ ...base,
238
+ kind: "plain",
239
+ marker: "",
240
+ speaker: "",
241
+ };
242
+ }
243
+
244
+ // Bare system notices: dim text, no extra marker competing with ›/◆/◇/✓.
245
+ return {
246
+ ...base,
247
+ kind: "system",
248
+ marker: " ",
249
+ speaker: "",
250
+ };
251
+ }
252
+ return base;
253
+ }
254
+
124
255
  function defaultMarkerForKind(kind = "", speaker = "") {
256
+ if (kind === "user") return "›";
125
257
  if (kind === "assistant") return "◆";
126
- if (kind === "agent") return "";
258
+ if (kind === "agent") return "";
259
+ if (kind === "report") return "▣";
127
260
  if (kind === "error") return "!";
128
261
  if (kind === "success") return "✓";
129
262
  if (kind === "divider") return "─";
130
- if (kind === "meta") return "·";
263
+ if (kind === "meta" || kind === "system") return "·";
131
264
  if (kind === "banner" || kind === "spacer") return " ";
132
- return speaker ? "" : "";
265
+ return speaker ? "" : "";
266
+ }
267
+
268
+ function markerTextForRow(kind, marker, speaker) {
269
+ if (kind === "user") return marker === "›" ? "› " : " ";
270
+ if (speaker) return `${marker || " "} `;
271
+ if (kind === "system") return marker && marker !== " " && marker !== "·" ? `${marker} ` : " ";
272
+ return `${marker || " "} `;
133
273
  }
134
274
 
135
275
  function buildChatLogLineModel(input = "", options = {}) {
136
276
  const markdownState = options && options.markdownState;
277
+ const optionSourceType = options && options.sourceType != null ? options.sourceType : "";
278
+ const optionMeta = options && options.meta && typeof options.meta === "object" ? options.meta : {};
137
279
 
138
- if (input && typeof input === "object" && !input.kind) {
280
+ if (input && typeof input === "object" && !input.kind && input.text == null && input.body == null) {
139
281
  return buildChatLogLineModel(chatLogEntryText(input), options);
140
282
  }
141
283
 
142
- if (input && typeof input === "object" && input.kind) {
143
- // Prefer original text when present so markdown can be (re)applied with
144
- // a shared fence state e.g. Static decoration.
145
- if (input.text != null && String(input.text).length > 0 && markdownState) {
146
- return buildChatLogLineModel(String(input.text), options);
284
+ if (input && typeof input === "object" && (input.kind || input.text != null || input.sourceType || input.type)) {
285
+ const sourceType = String(optionSourceType || input.sourceType || input.type || "");
286
+ const meta = input.meta && typeof input.meta === "object" && !Array.isArray(input.meta)
287
+ ? { ...optionMeta, ...input.meta }
288
+ : optionMeta;
289
+ const text = input.text != null ? String(input.text) : chatLogEntryText(input);
290
+
291
+ // Prefer re-deriving from text + sourceType when markdown fence state must
292
+ // advance across lines; otherwise reuse a pre-classified object.
293
+ if (!input.kind || sourceType || markdownState) {
294
+ const classified = applySourceTypeToRow(classifyChatLogLine(text), sourceType, meta);
295
+ const kind = classified.kind;
296
+ const speaker = classified.speaker;
297
+ const marker = classified.marker != null ? classified.marker : defaultMarkerForKind(kind, speaker);
298
+ const rawBody = kind === "user"
299
+ ? stripUserPromptPrefix(classified.body || " ")
300
+ : kind === "plain"
301
+ ? compactContinuationIndent(classified.body || " ")
302
+ : (classified.body || " ");
303
+ const bodyText = formatChatLogBody(rawBody, kind, markdownState || { inCodeBlock: false });
304
+ return {
305
+ kind,
306
+ marker,
307
+ speaker,
308
+ body: classified.body,
309
+ markerText: markerTextForRow(kind, marker, speaker),
310
+ bodyText,
311
+ };
147
312
  }
313
+
148
314
  const kind = String(input.kind || "plain");
149
315
  const speaker = String(input.speaker || "");
150
316
  const marker = input.marker != null ? String(input.marker) : defaultMarkerForKind(kind, speaker);
@@ -162,40 +328,64 @@ function buildChatLogLineModel(input = "", options = {}) {
162
328
  body: input.body != null ? String(input.body) : rawBody,
163
329
  markerText: input.markerText != null
164
330
  ? String(input.markerText)
165
- : (speaker ? `${marker || " "} ` : `${marker || " "} `),
331
+ : markerTextForRow(kind, marker, speaker),
166
332
  bodyText,
167
333
  };
168
334
  }
169
335
 
170
- const row = classifyChatLogLine(input);
171
- const hasSpeaker = Boolean(row.speaker);
336
+ const sourceType = String(optionSourceType || "");
337
+ const row = applySourceTypeToRow(classifyChatLogLine(input), sourceType, optionMeta);
172
338
  const body = row.kind === "plain"
173
339
  ? compactContinuationIndent(row.body || " ")
174
- : (row.body || " ");
340
+ : row.kind === "user"
341
+ ? stripUserPromptPrefix(row.body || " ")
342
+ : (row.body || " ");
175
343
  return {
176
344
  ...row,
177
- markerText: hasSpeaker ? `${row.marker || " "} ` : `${row.marker || " "} `,
345
+ markerText: markerTextForRow(row.kind, row.marker, row.speaker),
178
346
  bodyText: formatChatLogBody(body, row.kind, markdownState || { inCodeBlock: false }),
179
347
  };
180
348
  }
181
349
 
182
350
  function normalizeEntryInput(input) {
183
- if (input && typeof input === "object" && !Array.isArray(input)) return input;
184
- return { text: input };
351
+ if (!input || typeof input !== "object" || Array.isArray(input)) {
352
+ return { text: input };
353
+ }
354
+ // Defend against nested payloads like `{ text: { text, sourceType } }`
355
+ // (e.g. history objects fed through createInitialState as `text: line`).
356
+ let text = input.text;
357
+ let sourceType = input.sourceType || input.type || "";
358
+ let meta = input.meta;
359
+ if (text && typeof text === "object" && !Array.isArray(text)) {
360
+ sourceType = sourceType || text.sourceType || text.type || "";
361
+ if (!meta && text.meta && typeof text.meta === "object") meta = text.meta;
362
+ text = text.text != null ? text.text : chatLogEntryText(text);
363
+ }
364
+ return {
365
+ ...input,
366
+ text: text != null ? text : "",
367
+ sourceType,
368
+ type: sourceType || input.type || "",
369
+ meta: meta && typeof meta === "object" && !Array.isArray(meta) ? meta : {},
370
+ };
185
371
  }
186
372
 
187
373
  function createChatLogEntry(input = "", id = "", options = {}) {
188
374
  const source = normalizeEntryInput(input);
189
- const text = String(source.text != null ? source.text : chatLogEntryText(source));
375
+ const text = String(source.text != null ? source.text : "");
190
376
  const markdownState = options && options.markdownState
191
377
  ? options.markdownState
192
378
  : { inCodeBlock: false };
193
- const row = source.kind && !(options && options.markdownState)
194
- ? buildChatLogLineModel({ ...source, text }, { markdownState })
195
- : buildChatLogLineModel(text, { markdownState });
196
379
  const meta = source.meta && typeof source.meta === "object" && !Array.isArray(source.meta)
197
380
  ? { ...source.meta }
198
381
  : {};
382
+ const sourceType = String(source.sourceType || source.type || options.sourceType || "");
383
+ const row = buildChatLogLineModel({
384
+ ...source,
385
+ text,
386
+ sourceType,
387
+ meta,
388
+ }, { markdownState, sourceType, meta });
199
389
  const entry = {
200
390
  id: String(id || source.id || ""),
201
391
  text,
@@ -205,7 +395,7 @@ function createChatLogEntry(input = "", id = "", options = {}) {
205
395
  body: row.body,
206
396
  markerText: row.markerText,
207
397
  bodyText: row.bodyText,
208
- sourceType: String(source.sourceType || source.type || ""),
398
+ sourceType,
209
399
  meta,
210
400
  };
211
401
  return entry;
@@ -215,18 +405,25 @@ function chatLogEntryText(entry = "") {
215
405
  if (typeof entry === "string") return entry;
216
406
  if (!entry || typeof entry !== "object") return "";
217
407
  if (entry.text != null) return String(entry.text);
408
+ if (entry.kind === "user") {
409
+ const body = stripUserPromptPrefix(entry.bodyText || entry.body || "");
410
+ return body ? `› ${body}` : "›";
411
+ }
218
412
  if (entry.speaker) return `${entry.speaker} · ${entry.bodyText || entry.body || ""}`;
219
413
  return String(entry.bodyText || entry.body || "");
220
414
  }
221
415
 
222
416
  function canAppendToChatLogGroup(group, row) {
223
417
  if (!group || !row) return false;
418
+ if (group.kind === "user" && row.kind === "user" && row.marker !== "›") return true;
224
419
  if (row.kind !== "plain" && row.kind !== "spacer") return false;
225
420
  return group.kind === "assistant"
226
421
  || group.kind === "agent"
422
+ || group.kind === "report"
227
423
  || group.kind === "success"
228
424
  || group.kind === "error"
229
425
  || group.kind === "meta"
426
+ || group.kind === "system"
230
427
  || group.kind === "plain";
231
428
  }
232
429
 
@@ -241,13 +438,20 @@ function buildChatLogGroups(items = []) {
241
438
  const text = item && typeof item === "object" && item.text != null
242
439
  ? String(item.text)
243
440
  : chatLogEntryText(item);
244
- const row = buildChatLogLineModel(text, { markdownState });
441
+ const sourceType = item && typeof item === "object" ? String(item.sourceType || item.type || "") : "";
442
+ const meta = item && typeof item === "object" && item.meta ? item.meta : {};
443
+ const row = buildChatLogLineModel(
444
+ item && typeof item === "object"
445
+ ? { ...item, text, sourceType, meta }
446
+ : text,
447
+ { markdownState, sourceType, meta }
448
+ );
245
449
  const entry = {
246
450
  id: itemId,
247
451
  text,
248
452
  row,
249
- sourceType: item && typeof item === "object" ? String(item.sourceType || item.type || "") : "",
250
- meta: item && typeof item === "object" && item.meta ? item.meta : {},
453
+ sourceType,
454
+ meta,
251
455
  continuation: false,
252
456
  };
253
457
 
@@ -270,9 +474,11 @@ function buildChatLogGroups(items = []) {
270
474
  module.exports = {
271
475
  stripBlessedTags,
272
476
  stripMarkdownDecorators,
477
+ stripUserPromptPrefix,
273
478
  compactContinuationIndent,
274
479
  compactDividerLabel,
275
480
  classifyChatLogLine,
481
+ applySourceTypeToRow,
276
482
  buildChatLogLineModel,
277
483
  formatChatLogBody,
278
484
  createChatLogEntry,
@@ -101,12 +101,20 @@ function createInitialState({ banner = [], globalMode = false, globalScope = "co
101
101
  const initialLaunchMode = settings.launchMode || "auto";
102
102
  const initialAgentProvider = settings.agentProvider || "codex-cli";
103
103
  const selectedProviderIndex = Math.max(0, DEFAULT_PROVIDER_OPTIONS.findIndex((opt) => opt.value === initialAgentProvider));
104
+ const seed = Array.isArray(banner) ? banner.concat([""]) : [""];
104
105
  return {
105
- logLines: banner.concat([""]).map((line, idx) => createChatLogEntry({
106
- text: line,
107
- sourceType: "banner",
108
- }, `b-${idx}`)),
109
- lineSeq: banner.length + 1,
106
+ logLines: seed.map((line, idx) => {
107
+ // History reload may pass structured `{ text, sourceType }` rows —
108
+ // don't wrap those objects as `text` (that yields "[object Object]").
109
+ if (line && typeof line === "object" && !Array.isArray(line)) {
110
+ return createChatLogEntry(line, `b-${idx}`);
111
+ }
112
+ return createChatLogEntry({
113
+ text: line,
114
+ sourceType: "banner",
115
+ }, `b-${idx}`);
116
+ }),
117
+ lineSeq: seed.length,
110
118
  draft: "",
111
119
  focusMode: "input",
112
120
  dashboardView: globalMode ? "projects" : "agents",
@@ -446,7 +454,11 @@ function reducer(state, action) {
446
454
  const annotated = prefix && lines.length > 0
447
455
  ? [`${prefix}${lines[0]}`, ...lines.slice(1).map((l) => ` ${l}`)]
448
456
  : lines;
449
- const next = appendLog(state, annotated);
457
+ const next = appendLog(state, annotated.map((text) => ({
458
+ text,
459
+ type: "bus",
460
+ sourceType: "bus",
461
+ })));
450
462
  return { ...next, activeStream: null };
451
463
  }
452
464
  case "agentView/enter":