@alisio/alisio-code 0.1.0-alpha.15 → 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/tui/app.js +84 -13
- package/dist/tui/components.d.ts +71 -11
- package/dist/tui/components.js +465 -57
- package/dist/tui/state.d.ts +166 -1
- package/dist/tui/state.js +429 -12
- package/dist/tui/theme.d.ts +42 -1
- package/dist/tui/theme.js +516 -1
- package/package.json +4 -4
package/dist/tui/state.js
CHANGED
|
@@ -508,19 +508,40 @@ export function addItem(state, item) {
|
|
|
508
508
|
const withModel = (stats, model) => typeof model === "string" && model && !stats.models.includes(model)
|
|
509
509
|
? { ...stats, models: [...stats.models, model] }
|
|
510
510
|
: stats;
|
|
511
|
-
|
|
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()) {
|
|
512
523
|
const last = state.items.at(-1);
|
|
513
524
|
if (last?.kind === "assistant" && !last.done)
|
|
514
525
|
return {
|
|
515
526
|
...state,
|
|
516
|
-
items: [
|
|
527
|
+
items: [
|
|
528
|
+
...state.items.slice(0, -1),
|
|
529
|
+
withReasoningStamp({ ...last, [field]: last[field] + delta }, field, at),
|
|
530
|
+
],
|
|
517
531
|
};
|
|
518
|
-
return addItem(state, {
|
|
532
|
+
return addItem(state, withReasoningStamp({
|
|
519
533
|
kind: "assistant",
|
|
520
534
|
text: field === "text" ? delta : "",
|
|
521
535
|
reasoning: field === "reasoning" ? delta : "",
|
|
522
536
|
done: false,
|
|
523
|
-
});
|
|
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);
|
|
524
545
|
}
|
|
525
546
|
function updateTool(state, id, update) {
|
|
526
547
|
const index = state.items.findLastIndex((i) => i.kind === "tool" && i.id === id);
|
|
@@ -543,9 +564,9 @@ export function reduceEvent(state, event) {
|
|
|
543
564
|
stats: withModel({ ...state.stats, runs: state.stats.runs + 1 }, d.model),
|
|
544
565
|
};
|
|
545
566
|
case "text_delta":
|
|
546
|
-
return appendAssistant(state, "text", String(d.delta ?? ""));
|
|
567
|
+
return appendAssistant(state, "text", String(d.delta ?? ""), at);
|
|
547
568
|
case "reasoning_delta":
|
|
548
|
-
return appendAssistant(state, "reasoning", String(d.delta ?? ""));
|
|
569
|
+
return appendAssistant(state, "reasoning", String(d.delta ?? ""), at);
|
|
549
570
|
case "turn_completed": {
|
|
550
571
|
const usage = d.usage;
|
|
551
572
|
const last = state.items.at(-1);
|
|
@@ -567,15 +588,18 @@ export function reduceEvent(state, event) {
|
|
|
567
588
|
},
|
|
568
589
|
};
|
|
569
590
|
}
|
|
570
|
-
case "tool_started":
|
|
591
|
+
case "tool_started": {
|
|
592
|
+
const name = String(d.name ?? "tool");
|
|
571
593
|
return addItem(state, {
|
|
572
594
|
kind: "tool",
|
|
573
595
|
id: String(d.id ?? ""),
|
|
574
|
-
name
|
|
596
|
+
name,
|
|
575
597
|
args: typeof d.arguments === "string" ? d.arguments : "",
|
|
576
598
|
summary: summarizeToolArgs(String(d.name ?? ""), typeof d.arguments === "string" ? d.arguments : ""),
|
|
577
599
|
status: "running",
|
|
600
|
+
toolKind: toolKindOf(name, typeof d.effect === "string" ? d.effect : undefined),
|
|
578
601
|
});
|
|
602
|
+
}
|
|
579
603
|
case "approval_requested":
|
|
580
604
|
return updateTool(state, d.id, { status: "approval" });
|
|
581
605
|
case "approval_resolved":
|
|
@@ -587,6 +611,9 @@ export function reduceEvent(state, event) {
|
|
|
587
611
|
status: d.isError ? "error" : "ok",
|
|
588
612
|
...(typeof d.durationMs === "number" ? { durationMs: d.durationMs } : {}),
|
|
589
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 } : {}),
|
|
590
617
|
});
|
|
591
618
|
return {
|
|
592
619
|
...next,
|
|
@@ -696,6 +723,391 @@ export function lastAssistantText(items) {
|
|
|
696
723
|
}
|
|
697
724
|
return undefined;
|
|
698
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
|
+
}
|
|
699
1111
|
/** Rebuilds transcript items from persisted history (used by /resume). */
|
|
700
1112
|
export function itemsFromHistory(messages) {
|
|
701
1113
|
const items = [];
|
|
@@ -711,6 +1123,11 @@ export function itemsFromHistory(messages) {
|
|
|
711
1123
|
items.push({ kind: "assistant", text: m.text, reasoning: "", done: true });
|
|
712
1124
|
for (const c of m.calls) {
|
|
713
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);
|
|
714
1131
|
items.push({
|
|
715
1132
|
kind: "tool",
|
|
716
1133
|
id: c.id,
|
|
@@ -718,10 +1135,10 @@ export function itemsFromHistory(messages) {
|
|
|
718
1135
|
args: c.arguments,
|
|
719
1136
|
summary: summarizeToolArgs(c.name, c.arguments),
|
|
720
1137
|
status: r?.result.isError ? "error" : "ok",
|
|
721
|
-
preview
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
1138
|
+
preview,
|
|
1139
|
+
toolKind: toolKindOf(c.name),
|
|
1140
|
+
...exitCodePart(preview),
|
|
1141
|
+
...richPartsOf(r?.result),
|
|
725
1142
|
});
|
|
726
1143
|
}
|
|
727
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;
|