@alisio/alisio-code 0.1.0-alpha.14 → 0.1.0-alpha.16
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.
- package/dist/main.js +21 -2
- package/dist/tui/agents.d.ts +66 -0
- package/dist/tui/agents.js +113 -0
- package/dist/tui/app.js +260 -15
- package/dist/tui/components.d.ts +77 -12
- package/dist/tui/components.js +490 -63
- package/dist/tui/state.d.ts +204 -1
- package/dist/tui/state.js +501 -12
- package/dist/tui/theme.d.ts +42 -1
- package/dist/tui/theme.js +516 -1
- package/package.json +6 -6
package/dist/tui/state.js
CHANGED
|
@@ -229,6 +229,16 @@ export const COMMANDS = [
|
|
|
229
229
|
aliases: ["prefs"],
|
|
230
230
|
},
|
|
231
231
|
{ name: "copy", description: "Copy the last assistant response to the clipboard" },
|
|
232
|
+
{
|
|
233
|
+
name: "agents",
|
|
234
|
+
description: "List and switch the active agent (applies from the next prompt)",
|
|
235
|
+
argumentHint: "[<verb>]",
|
|
236
|
+
},
|
|
237
|
+
{
|
|
238
|
+
name: "effort",
|
|
239
|
+
description: "Set the reasoning effort level for the active model",
|
|
240
|
+
argumentHint: "[level]",
|
|
241
|
+
},
|
|
232
242
|
{
|
|
233
243
|
name: "ask",
|
|
234
244
|
description: "Ask the agent to turn your question into a multiple-choice ask_user_question",
|
|
@@ -250,6 +260,68 @@ export function parseCommand(input) {
|
|
|
250
260
|
return undefined;
|
|
251
261
|
return { name: match[1], args: (match[2] ?? "").trim() };
|
|
252
262
|
}
|
|
263
|
+
/**
|
|
264
|
+
* The effective reasoning effort to send for a model: the persisted level when the model
|
|
265
|
+
* supports it, otherwise the model's default level (silent fallback), and nothing when the
|
|
266
|
+
* model advertises no effort levels at all.
|
|
267
|
+
*/
|
|
268
|
+
export function effectiveEffort(persisted, capability) {
|
|
269
|
+
if (!capability?.supportedLevels?.length)
|
|
270
|
+
return undefined;
|
|
271
|
+
if (persisted && capability.supportedLevels.includes(persisted))
|
|
272
|
+
return persisted;
|
|
273
|
+
return capability.defaultLevel;
|
|
274
|
+
}
|
|
275
|
+
/** Validates a `/effort <level>` argument against the active model; returns an error or nothing. */
|
|
276
|
+
export function validateEffortLevel(level, capability) {
|
|
277
|
+
if (!capability?.supportedLevels?.length)
|
|
278
|
+
return "This model does not advertise reasoning effort levels, so no effort is sent to it.";
|
|
279
|
+
if (!capability.supportedLevels.includes(level))
|
|
280
|
+
return `Unsupported effort level "${level}". Supported levels: ${capability.supportedLevels.join(", ")}.`;
|
|
281
|
+
return undefined;
|
|
282
|
+
}
|
|
283
|
+
export function effortPickerItems(capability, active) {
|
|
284
|
+
const items = capability.supportedLevels.map((level) => ({
|
|
285
|
+
value: level,
|
|
286
|
+
label: [
|
|
287
|
+
level,
|
|
288
|
+
level === active ? "(current)" : undefined,
|
|
289
|
+
level === capability.defaultLevel ? "(default)" : undefined,
|
|
290
|
+
]
|
|
291
|
+
.filter(Boolean)
|
|
292
|
+
.join(" · "),
|
|
293
|
+
...(level === capability.defaultLevel ? { description: "The model's own default" } : {}),
|
|
294
|
+
}));
|
|
295
|
+
return [
|
|
296
|
+
...items,
|
|
297
|
+
{
|
|
298
|
+
value: "!clear",
|
|
299
|
+
label: "Auto · provider default",
|
|
300
|
+
description: "Clear the saved effort; the provider uses its own default",
|
|
301
|
+
},
|
|
302
|
+
];
|
|
303
|
+
}
|
|
304
|
+
/**
|
|
305
|
+
* The ordered identity parts for the status line below the editor:
|
|
306
|
+
* `agent: <name> · <model> · <provider> · <effort>`, with the effort part omitted when the
|
|
307
|
+
* active model advertises no supported levels (`effort === undefined`). Colors are applied by
|
|
308
|
+
* the renderer per role; this module stays terminal-free and testable.
|
|
309
|
+
*/
|
|
310
|
+
export function identityParts(input) {
|
|
311
|
+
const parts = [
|
|
312
|
+
{ text: `agent: ${input.agent}`, priority: 10, role: "agent" },
|
|
313
|
+
{ text: input.model || "not connected", priority: 9, role: "model" },
|
|
314
|
+
];
|
|
315
|
+
if (input.provider)
|
|
316
|
+
parts.push({ text: input.provider, priority: 8, role: "provider" });
|
|
317
|
+
if (input.effort)
|
|
318
|
+
parts.push({ text: input.effort, priority: 7, role: "effort" });
|
|
319
|
+
return parts;
|
|
320
|
+
}
|
|
321
|
+
/** Drops the lowest-priority identity parts until the line fits `width`; truncates the survivor. */
|
|
322
|
+
export function fitIdentityParts(input, width) {
|
|
323
|
+
return fitSegments(identityParts(input), width, " · ");
|
|
324
|
+
}
|
|
253
325
|
export const SKILL_COMPLETION_DESCRIPTION_LIMIT = 80;
|
|
254
326
|
/** Status hint prefixes (no color): shadowed < disabled < locked states shown by /skills. */
|
|
255
327
|
const skillStatus = (skill) => !skill.effective
|
|
@@ -436,19 +508,40 @@ export function addItem(state, item) {
|
|
|
436
508
|
const withModel = (stats, model) => typeof model === "string" && model && !stats.models.includes(model)
|
|
437
509
|
? { ...stats, models: [...stats.models, model] }
|
|
438
510
|
: stats;
|
|
439
|
-
|
|
511
|
+
/**
|
|
512
|
+
* Streaming assistant items stamp the thinking interval: the first reasoning delta records
|
|
513
|
+
* `reasoningStartedAt` and every later one refreshes `reasoningDoneAt`, so a completed thought
|
|
514
|
+
* section can show a `Thought · 2.9s` duration. The stamp stops once text arrives (the model
|
|
515
|
+
* moved on to the answer), so the duration measures the visible reasoning span, never the turn.
|
|
516
|
+
*/
|
|
517
|
+
const withReasoningStamp = (item, field, at) => {
|
|
518
|
+
if (field !== "reasoning" || item.text)
|
|
519
|
+
return item;
|
|
520
|
+
return { ...item, reasoningStartedAt: item.reasoningStartedAt ?? at, reasoningDoneAt: at };
|
|
521
|
+
};
|
|
522
|
+
function appendAssistant(state, field, delta, at = Date.now()) {
|
|
440
523
|
const last = state.items.at(-1);
|
|
441
524
|
if (last?.kind === "assistant" && !last.done)
|
|
442
525
|
return {
|
|
443
526
|
...state,
|
|
444
|
-
items: [
|
|
527
|
+
items: [
|
|
528
|
+
...state.items.slice(0, -1),
|
|
529
|
+
withReasoningStamp({ ...last, [field]: last[field] + delta }, field, at),
|
|
530
|
+
],
|
|
445
531
|
};
|
|
446
|
-
return addItem(state, {
|
|
532
|
+
return addItem(state, withReasoningStamp({
|
|
447
533
|
kind: "assistant",
|
|
448
534
|
text: field === "text" ? delta : "",
|
|
449
535
|
reasoning: field === "reasoning" ? delta : "",
|
|
450
536
|
done: false,
|
|
451
|
-
});
|
|
537
|
+
}, field, at));
|
|
538
|
+
}
|
|
539
|
+
/** Approximated thinking interval of a completed reasoning section, when the events carried timestamps. */
|
|
540
|
+
export function reasoningDurationMs(item) {
|
|
541
|
+
const { reasoningStartedAt, reasoningDoneAt } = item;
|
|
542
|
+
if (reasoningStartedAt === undefined || reasoningDoneAt === undefined)
|
|
543
|
+
return undefined;
|
|
544
|
+
return Math.max(0, reasoningDoneAt - reasoningStartedAt);
|
|
452
545
|
}
|
|
453
546
|
function updateTool(state, id, update) {
|
|
454
547
|
const index = state.items.findLastIndex((i) => i.kind === "tool" && i.id === id);
|
|
@@ -471,9 +564,9 @@ export function reduceEvent(state, event) {
|
|
|
471
564
|
stats: withModel({ ...state.stats, runs: state.stats.runs + 1 }, d.model),
|
|
472
565
|
};
|
|
473
566
|
case "text_delta":
|
|
474
|
-
return appendAssistant(state, "text", String(d.delta ?? ""));
|
|
567
|
+
return appendAssistant(state, "text", String(d.delta ?? ""), at);
|
|
475
568
|
case "reasoning_delta":
|
|
476
|
-
return appendAssistant(state, "reasoning", String(d.delta ?? ""));
|
|
569
|
+
return appendAssistant(state, "reasoning", String(d.delta ?? ""), at);
|
|
477
570
|
case "turn_completed": {
|
|
478
571
|
const usage = d.usage;
|
|
479
572
|
const last = state.items.at(-1);
|
|
@@ -495,15 +588,18 @@ export function reduceEvent(state, event) {
|
|
|
495
588
|
},
|
|
496
589
|
};
|
|
497
590
|
}
|
|
498
|
-
case "tool_started":
|
|
591
|
+
case "tool_started": {
|
|
592
|
+
const name = String(d.name ?? "tool");
|
|
499
593
|
return addItem(state, {
|
|
500
594
|
kind: "tool",
|
|
501
595
|
id: String(d.id ?? ""),
|
|
502
|
-
name
|
|
596
|
+
name,
|
|
503
597
|
args: typeof d.arguments === "string" ? d.arguments : "",
|
|
504
598
|
summary: summarizeToolArgs(String(d.name ?? ""), typeof d.arguments === "string" ? d.arguments : ""),
|
|
505
599
|
status: "running",
|
|
600
|
+
toolKind: toolKindOf(name, typeof d.effect === "string" ? d.effect : undefined),
|
|
506
601
|
});
|
|
602
|
+
}
|
|
507
603
|
case "approval_requested":
|
|
508
604
|
return updateTool(state, d.id, { status: "approval" });
|
|
509
605
|
case "approval_resolved":
|
|
@@ -515,6 +611,9 @@ export function reduceEvent(state, event) {
|
|
|
515
611
|
status: d.isError ? "error" : "ok",
|
|
516
612
|
...(typeof d.durationMs === "number" ? { durationMs: d.durationMs } : {}),
|
|
517
613
|
...(typeof d.preview === "string" ? { preview: d.preview } : {}),
|
|
614
|
+
...exitCodePart(d.preview),
|
|
615
|
+
...(isUiBlock(d.ui) ? { ui: d.ui } : {}),
|
|
616
|
+
...(isImagePart(d.image) ? { image: d.image } : {}),
|
|
518
617
|
});
|
|
519
618
|
return {
|
|
520
619
|
...next,
|
|
@@ -624,6 +723,391 @@ export function lastAssistantText(items) {
|
|
|
624
723
|
}
|
|
625
724
|
return undefined;
|
|
626
725
|
}
|
|
726
|
+
/**
|
|
727
|
+
* True when a printable key should copy the last assistant response instead of typing: bound to
|
|
728
|
+
* `c`/`y` (and uppercase) on an EMPTY editor line, while no autocomplete is showing and no turn
|
|
729
|
+
* is running — the same "empty input" convention Enter and Ctrl+D already use. A non-empty input
|
|
730
|
+
* never triggers, so typing `c` or `y` mid-message is unaffected.
|
|
731
|
+
*/
|
|
732
|
+
export function editorCopyKey(data, input) {
|
|
733
|
+
return ((data === "c" || data === "C" || data === "y" || data === "Y") &&
|
|
734
|
+
input.text === "" &&
|
|
735
|
+
!input.autocomplete &&
|
|
736
|
+
!input.busy);
|
|
737
|
+
}
|
|
738
|
+
/** True when an unknown event value is a `Node` of a tree UI block. */
|
|
739
|
+
export function isTreeNode(value) {
|
|
740
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
741
|
+
return false;
|
|
742
|
+
const record = value;
|
|
743
|
+
if (typeof record.label !== "string")
|
|
744
|
+
return false;
|
|
745
|
+
if (record.meta !== undefined && typeof record.meta !== "string")
|
|
746
|
+
return false;
|
|
747
|
+
if (record.children !== undefined && !Array.isArray(record.children))
|
|
748
|
+
return false;
|
|
749
|
+
return record.children === undefined || record.children.every(isTreeNode);
|
|
750
|
+
}
|
|
751
|
+
/** True when an unknown event value is an Alisio `UiBlock` (validated defensively). */
|
|
752
|
+
export function isUiBlock(value) {
|
|
753
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
754
|
+
return false;
|
|
755
|
+
const block = value;
|
|
756
|
+
switch (block.kind) {
|
|
757
|
+
case "table":
|
|
758
|
+
return (Array.isArray(block.columns) &&
|
|
759
|
+
block.columns.every((c) => typeof c === "string") &&
|
|
760
|
+
Array.isArray(block.rows) &&
|
|
761
|
+
block.rows.every((r) => Array.isArray(r) && r.every((c) => typeof c === "string")) &&
|
|
762
|
+
(block.caption === undefined || typeof block.caption === "string"));
|
|
763
|
+
case "key-value":
|
|
764
|
+
return (Array.isArray(block.entries) &&
|
|
765
|
+
block.entries.every((e) => Array.isArray(e) &&
|
|
766
|
+
e.length === 2 &&
|
|
767
|
+
typeof e[0] === "string" &&
|
|
768
|
+
typeof e[1] === "string") &&
|
|
769
|
+
(block.caption === undefined || typeof block.caption === "string"));
|
|
770
|
+
case "tree":
|
|
771
|
+
return Array.isArray(block.nodes) && block.nodes.every(isTreeNode);
|
|
772
|
+
case "code":
|
|
773
|
+
return (typeof block.code === "string" &&
|
|
774
|
+
(block.lang === undefined || typeof block.lang === "string") &&
|
|
775
|
+
(block.caption === undefined || typeof block.caption === "string"));
|
|
776
|
+
case "markdown":
|
|
777
|
+
return typeof block.text === "string";
|
|
778
|
+
default:
|
|
779
|
+
return false;
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
/** True when an unknown event value is a `{type:"image"}`-style part (mime + base64 data). */
|
|
783
|
+
export function isImagePart(value) {
|
|
784
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
785
|
+
return false;
|
|
786
|
+
const record = value;
|
|
787
|
+
return typeof record.mimeType === "string" && typeof record.data === "string";
|
|
788
|
+
}
|
|
789
|
+
/** Rich (ui/image) parts of a persisted tool result, if any. */
|
|
790
|
+
export function richPartsOf(result) {
|
|
791
|
+
if (!result)
|
|
792
|
+
return {};
|
|
793
|
+
const ui = result.content.find((part) => part.type === "ui");
|
|
794
|
+
const image = result.content.find((part) => part.type === "image");
|
|
795
|
+
return {
|
|
796
|
+
...(ui?.type === "ui" ? { ui: ui.block } : {}),
|
|
797
|
+
...(image?.type === "image" ? { image: { mimeType: image.mimeType, data: image.data } } : {}),
|
|
798
|
+
};
|
|
799
|
+
}
|
|
800
|
+
// ============================================================================
|
|
801
|
+
// Tool presentation model — the pure extension point for transcript display.
|
|
802
|
+
// Everything the TUI renders beyond a plain `TranscriptItem` is a
|
|
803
|
+
// `TranscriptEntry`, and anything collapsible declares a `FoldCandidate` here,
|
|
804
|
+
// so the renderer, the keybinding and (optionally) mouse clicks stay consistent
|
|
805
|
+
// without importing pi-tui. Future block kinds hook in by extending these
|
|
806
|
+
// shapes, never by special-casing components.
|
|
807
|
+
// ============================================================================
|
|
808
|
+
/** Words keyed by their canonical upper-case spelling that stay acronyms when humanized. */
|
|
809
|
+
const ACRONYM_WORDS = new Set([
|
|
810
|
+
"MCP",
|
|
811
|
+
"HTTP",
|
|
812
|
+
"HTTPS",
|
|
813
|
+
"URL",
|
|
814
|
+
"API",
|
|
815
|
+
"CLI",
|
|
816
|
+
"JSON",
|
|
817
|
+
"CSV",
|
|
818
|
+
"XML",
|
|
819
|
+
"HTML",
|
|
820
|
+
"SQL",
|
|
821
|
+
"SQLITE",
|
|
822
|
+
"DNS",
|
|
823
|
+
"SSH",
|
|
824
|
+
"IP",
|
|
825
|
+
"ID",
|
|
826
|
+
"DB",
|
|
827
|
+
"UI",
|
|
828
|
+
"OS",
|
|
829
|
+
"IDE",
|
|
830
|
+
"TUI",
|
|
831
|
+
"PNG",
|
|
832
|
+
"SVG",
|
|
833
|
+
"PDF",
|
|
834
|
+
"YAML",
|
|
835
|
+
"TOML",
|
|
836
|
+
"2FA",
|
|
837
|
+
"V2",
|
|
838
|
+
]);
|
|
839
|
+
/**
|
|
840
|
+
* `read_file` → `Read File`, `search_text` → `Search Text`, `mcp_devforge_time_diff` →
|
|
841
|
+
* `MCP · Devforge Time Diff`. Splits on `_`/`-`/`.`/spaces, keeps known acronyms (MCP, HTTP…)
|
|
842
|
+
* and bare numeric suffixes (`read_file_2` → `Read File 2`). The machine name stays available
|
|
843
|
+
* dimmed in grouped detail rows and in the `/tools` and `/stats` reports.
|
|
844
|
+
*/
|
|
845
|
+
export function humanizeToolName(name) {
|
|
846
|
+
const trimmed = name.trim();
|
|
847
|
+
let rest = trimmed;
|
|
848
|
+
let prefix = "";
|
|
849
|
+
const mcp = /^mcp_/i.exec(rest);
|
|
850
|
+
if (mcp) {
|
|
851
|
+
prefix = "MCP · ";
|
|
852
|
+
rest = rest.slice(mcp[0].length);
|
|
853
|
+
}
|
|
854
|
+
const words = rest
|
|
855
|
+
.split(/[_\-. /]+/)
|
|
856
|
+
.filter(Boolean)
|
|
857
|
+
.map((word) => {
|
|
858
|
+
const upper = word.toUpperCase();
|
|
859
|
+
if (ACRONYM_WORDS.has(upper))
|
|
860
|
+
return upper;
|
|
861
|
+
if (/^\d+$/.test(upper))
|
|
862
|
+
return upper;
|
|
863
|
+
return `${word[0]?.toUpperCase() ?? ""}${word.slice(1).toLowerCase()}`;
|
|
864
|
+
});
|
|
865
|
+
return words.length ? `${prefix}${words.join(" ")}` : trimmed;
|
|
866
|
+
}
|
|
867
|
+
/** Verb and count noun used by grouped batch rows per tool kind. */
|
|
868
|
+
export const TOOL_KIND_VERB = {
|
|
869
|
+
read: "Explored",
|
|
870
|
+
write: "Edited",
|
|
871
|
+
process: "Ran",
|
|
872
|
+
mcp: "Queried",
|
|
873
|
+
internal: "Ran",
|
|
874
|
+
other: "Called",
|
|
875
|
+
};
|
|
876
|
+
export const TOOL_KIND_WORD = {
|
|
877
|
+
read: "reads",
|
|
878
|
+
write: "files",
|
|
879
|
+
process: "commands",
|
|
880
|
+
mcp: "calls",
|
|
881
|
+
internal: "tasks",
|
|
882
|
+
other: "calls",
|
|
883
|
+
};
|
|
884
|
+
/**
|
|
885
|
+
* Capability kind of a tool call. Live events carry the registry `effect` (exact); replayed
|
|
886
|
+
* history falls back to conservative name heuristics. `mcp_*` is always the mcp kind.
|
|
887
|
+
*/
|
|
888
|
+
export function toolKindOf(name, effect) {
|
|
889
|
+
if (name.startsWith("mcp_"))
|
|
890
|
+
return "mcp";
|
|
891
|
+
switch (effect) {
|
|
892
|
+
case "read":
|
|
893
|
+
case "write":
|
|
894
|
+
case "process":
|
|
895
|
+
case "internal":
|
|
896
|
+
return effect;
|
|
897
|
+
default:
|
|
898
|
+
break;
|
|
899
|
+
}
|
|
900
|
+
if (/^(read|search|list|git|context|ask|web|fetch)_/.test(name))
|
|
901
|
+
return "read";
|
|
902
|
+
if (/^(write|edit|append|delete|rename|move|mkdir|patch)_/.test(name))
|
|
903
|
+
return "write";
|
|
904
|
+
if (/^(run|shell|execute)/.test(name))
|
|
905
|
+
return "process";
|
|
906
|
+
if (name === "task" || name.startsWith("task_"))
|
|
907
|
+
return "internal";
|
|
908
|
+
return "other";
|
|
909
|
+
}
|
|
910
|
+
/** Terminal-preview caps for collapsed rows (same values the renderer always used). */
|
|
911
|
+
export const OUTPUT_PREVIEW_LINES_OK = 3;
|
|
912
|
+
export const OUTPUT_PREVIEW_LINES_ERROR = 6;
|
|
913
|
+
export const previewLinesFor = (status) => status === "error" ? OUTPUT_PREVIEW_LINES_ERROR : OUTPUT_PREVIEW_LINES_OK;
|
|
914
|
+
/** Preview rows as the renderer shows them: blank lines dropped, tabs kept for the caller to expand. */
|
|
915
|
+
export const previewRows = (preview) => preview.split("\n").filter((line) => line.trim());
|
|
916
|
+
/** True when the preview exceeds the collapsed cap (the row becomes a collapsible "output"). */
|
|
917
|
+
export function previewTruncated(preview, status) {
|
|
918
|
+
return !!preview && previewRows(preview).length > previewLinesFor(status);
|
|
919
|
+
}
|
|
920
|
+
/**
|
|
921
|
+
* Exit code derived safely from a text preview. `run_process`/`shell`/`search_text` return
|
|
922
|
+
* `{stdout, stderr, exitCode, truncated}` JSON; read-effect tools may carry an appended
|
|
923
|
+
* `\n<instructions>…` block, retried on the JSON prefix. Any other shape yields nothing.
|
|
924
|
+
*/
|
|
925
|
+
export function exitCodeOf(preview) {
|
|
926
|
+
if (!preview)
|
|
927
|
+
return undefined;
|
|
928
|
+
const codeOf = (text) => {
|
|
929
|
+
try {
|
|
930
|
+
const value = JSON.parse(text);
|
|
931
|
+
const code = value && typeof value === "object" && !Array.isArray(value)
|
|
932
|
+
? value.exitCode
|
|
933
|
+
: undefined;
|
|
934
|
+
return typeof code === "number" ? code : undefined;
|
|
935
|
+
}
|
|
936
|
+
catch {
|
|
937
|
+
return undefined;
|
|
938
|
+
}
|
|
939
|
+
};
|
|
940
|
+
const direct = codeOf(preview);
|
|
941
|
+
if (direct !== undefined)
|
|
942
|
+
return direct;
|
|
943
|
+
const cut = preview.indexOf("\n<instructions>");
|
|
944
|
+
if (cut > 0)
|
|
945
|
+
return codeOf(preview.slice(0, cut));
|
|
946
|
+
return undefined;
|
|
947
|
+
}
|
|
948
|
+
const exitCodePart = (preview) => {
|
|
949
|
+
if (typeof preview !== "string")
|
|
950
|
+
return {};
|
|
951
|
+
const code = exitCodeOf(preview);
|
|
952
|
+
return code === undefined ? {} : { exitCode: code };
|
|
953
|
+
};
|
|
954
|
+
export function toolItemView(item) {
|
|
955
|
+
return {
|
|
956
|
+
id: item.id,
|
|
957
|
+
name: item.name,
|
|
958
|
+
humanName: humanizeToolName(item.name),
|
|
959
|
+
kind: item.toolKind ?? toolKindOf(item.name),
|
|
960
|
+
summary: item.summary,
|
|
961
|
+
status: item.status,
|
|
962
|
+
...(item.durationMs !== undefined ? { durationMs: item.durationMs } : {}),
|
|
963
|
+
...(item.preview !== undefined ? { preview: item.preview } : {}),
|
|
964
|
+
...(item.ui ? { ui: item.ui } : {}),
|
|
965
|
+
...(item.image ? { image: item.image } : {}),
|
|
966
|
+
...(item.exitCode !== undefined ? { exitCode: item.exitCode } : {}),
|
|
967
|
+
};
|
|
968
|
+
}
|
|
969
|
+
/**
|
|
970
|
+
* Consecutive tool calls of the same kind whose statuses are ALL terminal (ok/error) merge into
|
|
971
|
+
* one `group` entry; running/approval calls always stay singleton so their spinner and live state
|
|
972
|
+
* stay visible. A kind change splits the run (`read` never merges with `shell`). Order is
|
|
973
|
+
* preserved; every entry carries its index within `items` (fold keys for assistant items need
|
|
974
|
+
* it). One linear pass — called on state changes only, never per frame.
|
|
975
|
+
*/
|
|
976
|
+
export function groupToolEntries(items) {
|
|
977
|
+
const out = [];
|
|
978
|
+
let run = [];
|
|
979
|
+
let runKind;
|
|
980
|
+
const flush = () => {
|
|
981
|
+
if (!run.length)
|
|
982
|
+
return;
|
|
983
|
+
const views = run.map((r) => toolItemView(r.item));
|
|
984
|
+
const first = views[0];
|
|
985
|
+
const groupable = views.length > 1 &&
|
|
986
|
+
first !== undefined &&
|
|
987
|
+
views.every((v) => v.kind === runKind) &&
|
|
988
|
+
views.every((v) => v.status === "ok" || v.status === "error");
|
|
989
|
+
if (groupable) {
|
|
990
|
+
const sameName = views.every((v) => v.name === first.name);
|
|
991
|
+
out.push({
|
|
992
|
+
entry: {
|
|
993
|
+
kind: "group",
|
|
994
|
+
group: {
|
|
995
|
+
id: first.id,
|
|
996
|
+
members: views,
|
|
997
|
+
kind: first.kind,
|
|
998
|
+
status: views.some((v) => v.status === "error") ? "error" : "ok",
|
|
999
|
+
totalMs: views.reduce((sum, v) => sum + (v.durationMs ?? 0), 0),
|
|
1000
|
+
label: sameName ? first.humanName : TOOL_KIND_VERB[first.kind],
|
|
1001
|
+
word: TOOL_KIND_WORD[first.kind],
|
|
1002
|
+
},
|
|
1003
|
+
},
|
|
1004
|
+
at: run[0]?.at ?? 0,
|
|
1005
|
+
});
|
|
1006
|
+
}
|
|
1007
|
+
else {
|
|
1008
|
+
for (const r of run)
|
|
1009
|
+
out.push({ entry: r.item, at: r.at });
|
|
1010
|
+
}
|
|
1011
|
+
run = [];
|
|
1012
|
+
runKind = undefined;
|
|
1013
|
+
};
|
|
1014
|
+
items.forEach((item, at) => {
|
|
1015
|
+
if (item.kind === "tool") {
|
|
1016
|
+
const kind = item.toolKind ?? toolKindOf(item.name);
|
|
1017
|
+
if (runKind === undefined) {
|
|
1018
|
+
runKind = kind;
|
|
1019
|
+
run.push({ item, at });
|
|
1020
|
+
}
|
|
1021
|
+
else if (runKind === kind) {
|
|
1022
|
+
run.push({ item, at });
|
|
1023
|
+
}
|
|
1024
|
+
else {
|
|
1025
|
+
flush();
|
|
1026
|
+
runKind = kind;
|
|
1027
|
+
run.push({ item, at });
|
|
1028
|
+
}
|
|
1029
|
+
}
|
|
1030
|
+
else {
|
|
1031
|
+
flush();
|
|
1032
|
+
out.push({ entry: item, at });
|
|
1033
|
+
}
|
|
1034
|
+
});
|
|
1035
|
+
flush();
|
|
1036
|
+
return out;
|
|
1037
|
+
}
|
|
1038
|
+
/** Content identity of a group (fold/diff purposes): member ids, statuses and durations. */
|
|
1039
|
+
export function groupIdentity(group) {
|
|
1040
|
+
return `${group.id}|${group.members.length}|${group.members
|
|
1041
|
+
.map((m) => `${m.id}:${m.status}:${m.durationMs ?? ""}`)
|
|
1042
|
+
.join(",")}`;
|
|
1043
|
+
}
|
|
1044
|
+
/** Cap for the reasoning text when expanded; longer sections show a truncation note. */
|
|
1045
|
+
export const REASONING_EXPAND_MAX_LINES = 40;
|
|
1046
|
+
/**
|
|
1047
|
+
* The fold contract of one transcript entry: nothing (not collapsible), or a candidate with its
|
|
1048
|
+
* default state. `at` is the entry's index within the ORIGINAL items array (assistant fold keys
|
|
1049
|
+
* are stable because transcript items are append-only).
|
|
1050
|
+
*/
|
|
1051
|
+
export function foldCandidateOf(entry, at) {
|
|
1052
|
+
if (entry.kind === "assistant") {
|
|
1053
|
+
const reasoning = entry.reasoning.trim();
|
|
1054
|
+
if (!reasoning || !(entry.done || entry.text))
|
|
1055
|
+
return undefined;
|
|
1056
|
+
return { key: `a:${at}`, kind: "reasoning", defaultExpanded: false };
|
|
1057
|
+
}
|
|
1058
|
+
if (entry.kind === "group")
|
|
1059
|
+
return { key: `g:${entry.group.id}`, kind: "group", defaultExpanded: false };
|
|
1060
|
+
if (entry.kind === "tool") {
|
|
1061
|
+
if (entry.status === "running" || entry.status === "approval")
|
|
1062
|
+
return undefined;
|
|
1063
|
+
// Rich blocks keep their native rendering (tables/trees/images are already bounded);
|
|
1064
|
+
// only plain text output folds when it exceeds the collapsed cap.
|
|
1065
|
+
if (entry.ui || entry.image)
|
|
1066
|
+
return undefined;
|
|
1067
|
+
if (previewTruncated(entry.preview, entry.status))
|
|
1068
|
+
return { key: `t:${entry.id}`, kind: "output", defaultExpanded: false };
|
|
1069
|
+
return undefined;
|
|
1070
|
+
}
|
|
1071
|
+
return undefined;
|
|
1072
|
+
}
|
|
1073
|
+
/** Stable key of a transcript entry (used by the component sync to reuse row instances). */
|
|
1074
|
+
export function entryKeyOf(entry, at) {
|
|
1075
|
+
switch (entry.kind) {
|
|
1076
|
+
case "assistant":
|
|
1077
|
+
return `a:${at}`;
|
|
1078
|
+
case "group":
|
|
1079
|
+
return `g:${entry.group.id}`;
|
|
1080
|
+
case "tool":
|
|
1081
|
+
return `t:${entry.id}`;
|
|
1082
|
+
case "user":
|
|
1083
|
+
return `u:${at}`;
|
|
1084
|
+
case "notice":
|
|
1085
|
+
return `n:${at}`;
|
|
1086
|
+
case "info":
|
|
1087
|
+
return `i:${at}`;
|
|
1088
|
+
case "error":
|
|
1089
|
+
return `e:${at}`;
|
|
1090
|
+
}
|
|
1091
|
+
}
|
|
1092
|
+
/** Every fold candidate of the transcript in display order; the keybinding targets the last one. */
|
|
1093
|
+
export function foldCandidates(items) {
|
|
1094
|
+
const candidates = [];
|
|
1095
|
+
for (const { entry, at } of groupToolEntries(items)) {
|
|
1096
|
+
const candidate = foldCandidateOf(entry, at);
|
|
1097
|
+
if (candidate)
|
|
1098
|
+
candidates.push(candidate);
|
|
1099
|
+
}
|
|
1100
|
+
return candidates;
|
|
1101
|
+
}
|
|
1102
|
+
/**
|
|
1103
|
+
* True when a printable key should toggle the nearest collapsible transcript row instead of
|
|
1104
|
+
* typing: bound to `x`/`X` on an EMPTY editor line (no autocomplete), the same "empty input"
|
|
1105
|
+
* convention Enter, Ctrl+D and `c`/`y` already use. Unlike copy, it also works while a turn runs
|
|
1106
|
+
* (the nearest candidate may still exist mid-stream). A non-empty input never triggers.
|
|
1107
|
+
*/
|
|
1108
|
+
export function foldToggleKey(data, input) {
|
|
1109
|
+
return (data === "x" || data === "X") && input.text === "" && !input.autocomplete;
|
|
1110
|
+
}
|
|
627
1111
|
/** Rebuilds transcript items from persisted history (used by /resume). */
|
|
628
1112
|
export function itemsFromHistory(messages) {
|
|
629
1113
|
const items = [];
|
|
@@ -639,6 +1123,11 @@ export function itemsFromHistory(messages) {
|
|
|
639
1123
|
items.push({ kind: "assistant", text: m.text, reasoning: "", done: true });
|
|
640
1124
|
for (const c of m.calls) {
|
|
641
1125
|
const r = results.get(c.id);
|
|
1126
|
+
const preview = r?.result.content
|
|
1127
|
+
.filter((p) => p.type === "text")
|
|
1128
|
+
.map((x) => x.text)
|
|
1129
|
+
.join("\n")
|
|
1130
|
+
.slice(0, 2_000);
|
|
642
1131
|
items.push({
|
|
643
1132
|
kind: "tool",
|
|
644
1133
|
id: c.id,
|
|
@@ -646,10 +1135,10 @@ export function itemsFromHistory(messages) {
|
|
|
646
1135
|
args: c.arguments,
|
|
647
1136
|
summary: summarizeToolArgs(c.name, c.arguments),
|
|
648
1137
|
status: r?.result.isError ? "error" : "ok",
|
|
649
|
-
preview
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
1138
|
+
preview,
|
|
1139
|
+
toolKind: toolKindOf(c.name),
|
|
1140
|
+
...exitCodePart(preview),
|
|
1141
|
+
...richPartsOf(r?.result),
|
|
653
1142
|
});
|
|
654
1143
|
}
|
|
655
1144
|
}
|
package/dist/tui/theme.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { EditorTheme, ImageTheme, MarkdownTheme, SelectListTheme } from "@earendil-works/pi-tui";
|
|
1
|
+
import type { DefaultTextStyle, EditorTheme, ImageTheme, MarkdownTheme, SelectListTheme } from "@earendil-works/pi-tui";
|
|
2
2
|
import type { Level } from "./state.ts";
|
|
3
3
|
export declare const style: {
|
|
4
4
|
bold: (text: string) => string;
|
|
@@ -17,6 +17,47 @@ export declare const style: {
|
|
|
17
17
|
userBg: (text: string) => string;
|
|
18
18
|
};
|
|
19
19
|
export declare const levelColor: (level: Level) => (text: string) => string;
|
|
20
|
+
/**
|
|
21
|
+
* Dependency-free, line-based syntax highlighting for code blocks in responses.
|
|
22
|
+
*
|
|
23
|
+
* The tokenizer is deliberately approximate: it walks each line once and colors keywords, strings,
|
|
24
|
+
* comments, numbers, function calls and property keys for the common response languages. It never
|
|
25
|
+
* tries to be a real parser, never throws on malformed input, and strips ANSI/control characters
|
|
26
|
+
* from the source first (a model response must never be able to inject terminal control codes).
|
|
27
|
+
*/
|
|
28
|
+
export type TokenStyle = "keyword" | "string" | "comment" | "number" | "function" | "property" | "plain";
|
|
29
|
+
export interface CodeToken {
|
|
30
|
+
text: string;
|
|
31
|
+
style: TokenStyle;
|
|
32
|
+
}
|
|
33
|
+
/** Strips ANSI escapes, OSC sequences and control characters; normalizes tabs. */
|
|
34
|
+
export declare function sanitizeCode(code: string): string;
|
|
35
|
+
export interface ScanState {
|
|
36
|
+
/** Closing delimiter of a block comment started on a previous line. */
|
|
37
|
+
inComment?: string;
|
|
38
|
+
/** Delimiter of a multi-line string started on a previous line. */
|
|
39
|
+
inString?: string;
|
|
40
|
+
}
|
|
41
|
+
/** Tokenizes code into styled segments, maintaining multi-line string/comment state. */
|
|
42
|
+
export declare function tokenizeCode(code: string, lang?: string): CodeToken[][];
|
|
43
|
+
/**
|
|
44
|
+
* Per-line highlighting for markdown code blocks (pi-tui `MarkdownTheme.highlightCode`).
|
|
45
|
+
* Returns one styled (or plain, under NO_COLOR) line per source line; never emits ANSI for
|
|
46
|
+
* bytes that came from the input.
|
|
47
|
+
*/
|
|
48
|
+
export declare function highlightCode(code: string, lang?: string): string[];
|
|
49
|
+
/**
|
|
50
|
+
* Normalizes table separator rows so models' sloppy ASCII art still renders as aligned tables:
|
|
51
|
+
* em/en/box dashes become plain `-` runs (marked's table tokenizer only accepts `-`), while
|
|
52
|
+
* alignment colons are preserved. Anything that is not an obvious separator row, including
|
|
53
|
+
* fenced or indented code, is left untouched. A bare `---` (horizontal rule / setext) has no
|
|
54
|
+
* `|` and is never touched.
|
|
55
|
+
*/
|
|
56
|
+
export declare function normalizeTableSeparators(markdown: string): string;
|
|
57
|
+
/** Pre-parse hook for the Markdown component: normalizes table separators before marked parses. */
|
|
58
|
+
export declare const markdownTransform: (markdown: string, _availableWidth: number) => string;
|
|
59
|
+
/** Subtle base tone for response body text; inline styles override it. */
|
|
60
|
+
export declare const markdownDefaultTextStyle: DefaultTextStyle;
|
|
20
61
|
export declare const markdownTheme: MarkdownTheme;
|
|
21
62
|
export declare const selectListTheme: SelectListTheme;
|
|
22
63
|
export declare const editorTheme: EditorTheme;
|