@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/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
- function appendAssistant(state, field, delta) {
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: [...state.items.slice(0, -1), { ...last, [field]: last[field] + delta }],
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: String(d.name ?? "tool"),
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: r?.result.content
650
- .map((x) => x.text)
651
- .join("\n")
652
- .slice(0, 2_000),
1138
+ preview,
1139
+ toolKind: toolKindOf(c.name),
1140
+ ...exitCodePart(preview),
1141
+ ...richPartsOf(r?.result),
653
1142
  });
654
1143
  }
655
1144
  }
@@ -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;