@songtonyli/dsh-cli 0.1.11 → 0.1.12

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.
@@ -416,6 +416,20 @@ function toolResultLines(view, content) {
416
416
  }
417
417
  }
418
418
  /**
419
+ * Cut rows to a maximum, naming what was left out on one trailing row. Every
420
+ * surface that shows part of something longer folds it this way - the collapsed
421
+ * tool card, an approval's call detail, the focused-section inspector - and each
422
+ * names the key that shows the rest.
423
+ * @param lines - the full rows.
424
+ * @param keep - rows kept ahead of the marker.
425
+ * @param marker - builds the trailing row from the number of rows left out.
426
+ * @returns the rows to draw; a copy of `lines` when they all fit.
427
+ */
428
+ function foldRows(lines, keep, marker) {
429
+ if (lines.length <= keep) return [...lines];
430
+ return [...lines.slice(0, keep), marker(lines.length - keep)];
431
+ }
432
+ /**
419
433
  * Cut a card body to its collapsed preview.
420
434
  * @param lines - the full body rows.
421
435
  * @param previewLines - rows kept when collapsed.
@@ -423,9 +437,8 @@ function toolResultLines(view, content) {
423
437
  * @returns the rows to draw, with a trailing count of hidden rows when cut.
424
438
  */
425
439
  function previewLines(lines, previewLines, expanded) {
426
- if (expanded || lines.length <= previewLines) return [...lines];
427
- const hidden = lines.length - previewLines;
428
- return [...lines.slice(0, previewLines), `… ${String(hidden)} more line${hidden === 1 ? "" : "s"} (Ctrl+O expands)`];
440
+ if (expanded) return [...lines];
441
+ return foldRows(lines, previewLines, (hidden) => `… ${String(hidden)} more line${hidden === 1 ? "" : "s"} (Ctrl+O expands)`);
429
442
  }
430
443
  //#endregion
431
444
  //#region lib/types/catalog.js
@@ -814,6 +827,16 @@ const LUMINANCE = {
814
827
  };
815
828
  /** Largest value one color channel encodes. */
816
829
  const CHANNEL_MAX = 255;
830
+ /** Encoded sRGB value below which the transfer function is the linear segment. */
831
+ const SRGB_LINEAR_CUT = .04045;
832
+ /** Linear intensity below which the sRGB transfer function is the linear segment. */
833
+ const LINEAR_SRGB_CUT = .0031308;
834
+ /** Slope of the sRGB transfer function's linear segment. */
835
+ const SRGB_LINEAR_SLOPE = 12.92;
836
+ /** Offset of the sRGB transfer function's power segment. */
837
+ const SRGB_OFFSET = .055;
838
+ /** Exponent of the sRGB transfer function's power segment. */
839
+ const SRGB_GAMMA = 2.4;
817
840
  /**
818
841
  * Grapheme segmenter for the reverse column walk. pi-tui keeps its own
819
842
  * segmenter private, so this module holds one; grapheme segmentation does not
@@ -842,44 +865,68 @@ function resolveFadeCapability(input) {
842
865
  return term.includes("256color") ? "ansi256" : "dim";
843
866
  }
844
867
  /**
845
- * Build the brightness ramp a chunk climbs, `ramp[k] = lerp(bg, fg, (k + 1) /
846
- * steps)` in sRGB with each channel rounded to a byte. The last level is a
847
- * copy of `fg` rather than a computed value, so settled text and the last
848
- * faded frame carry identical color.
868
+ * Build the brightness ramp a chunk climbs: level `k` sits at
869
+ * `t = (k + 1) / steps` of the way from `bg` to `fg`, eased by the smoothstep
870
+ * `t * t * (3 - 2 * t)` and interpolated with each channel in linear light.
871
+ * Both shape the ramp against what the eye reads rather than what the byte
872
+ * says: sRGB bytes are gamma-encoded, so mixing them directly bunches the
873
+ * visible change into the dark end, and an even ramp of levels arrives with a
874
+ * hard start and stop. The last level is a copy of `fg` rather than a computed
875
+ * value, so settled text and the last faded frame carry identical color.
849
876
  * @param bg - the terminal background color.
850
877
  * @param fg - the normal foreground color.
851
878
  * @param steps - brightness levels; defaults to {@link FADE_STEPS}.
852
879
  * @returns `steps` levels, darkest first.
853
880
  */
854
- function buildFadeRamp(bg, fg, steps = 5) {
881
+ function buildFadeRamp(bg, fg, steps = 8) {
855
882
  return Array.from({ length: steps }, (_unused, level) => {
856
883
  if (level === steps - 1) return {
857
884
  r: fg.r,
858
885
  g: fg.g,
859
886
  b: fg.b
860
887
  };
861
- const ratio = (level + 1) / steps;
888
+ const position = (level + 1) / steps;
889
+ const eased = position * position * (3 - 2 * position);
862
890
  return {
863
- r: mix(bg.r, fg.r, ratio),
864
- g: mix(bg.g, fg.g, ratio),
865
- b: mix(bg.b, fg.b, ratio)
891
+ r: mix(bg.r, fg.r, eased),
892
+ g: mix(bg.g, fg.g, eased),
893
+ b: mix(bg.b, fg.b, eased)
866
894
  };
867
895
  });
868
896
  }
869
897
  /**
870
- * Interpolate one channel.
871
- * @param from - the background channel.
872
- * @param to - the foreground channel.
898
+ * Interpolate one channel in linear light.
899
+ * @param from - the background channel, sRGB-encoded.
900
+ * @param to - the foreground channel, sRGB-encoded.
873
901
  * @param ratio - position between them, 0 at `from` and 1 at `to`.
874
- * @returns the channel value rounded to a byte.
902
+ * @returns the sRGB-encoded channel value rounded to a byte.
875
903
  */
876
904
  function mix(from, to, ratio) {
877
- return Math.round(from + (to - from) * ratio);
905
+ const linear = toLinear(from);
906
+ return toSrgb(linear + (toLinear(to) - linear) * ratio);
907
+ }
908
+ /**
909
+ * Decode one sRGB channel to linear light, by the sRGB transfer function.
910
+ * @param value - the channel byte.
911
+ * @returns the linear intensity in 0..1.
912
+ */
913
+ function toLinear(value) {
914
+ const encoded = value / CHANNEL_MAX;
915
+ return encoded <= SRGB_LINEAR_CUT ? encoded / SRGB_LINEAR_SLOPE : ((encoded + SRGB_OFFSET) / 1.055) ** SRGB_GAMMA;
916
+ }
917
+ /**
918
+ * Encode linear light back to an sRGB channel byte.
919
+ * @param value - the linear intensity in 0..1.
920
+ * @returns the channel byte.
921
+ */
922
+ function toSrgb(value) {
923
+ const encoded = value <= LINEAR_SRGB_CUT ? SRGB_LINEAR_SLOPE * value : 1.055 * value ** (1 / SRGB_GAMMA) - SRGB_OFFSET;
924
+ return Math.round(encoded * CHANNEL_MAX);
878
925
  }
879
926
  /**
880
927
  * The SGR sequence one age draws under.
881
928
  * @param style - the capability and the ramp.
882
- * @param age - ticks since the chunk arrived; ages past the ramp draw its last level.
929
+ * @param age - the brightness level; levels past the ramp draw its last one.
883
930
  * @returns the sequence to open the run with, or the empty string when the
884
931
  * chunk draws as the component rendered it, which is also what an empty ramp
885
932
  * yields under a color capability.
@@ -926,32 +973,66 @@ function channel(value) {
926
973
  function restoreFor(capability) {
927
974
  return capability === "dim" ? RESET_INTENSITY : RESET_FOREGROUND;
928
975
  }
976
+ /** Every SGR sequence, the sequences a block's own palette styling writes. */
977
+ const SGR_SEQUENCE = /\u001b\[[0-9;]*m/g;
929
978
  /**
930
- * The tail of one streaming assistant message: the chunks young enough to be
931
- * recolored, advanced one tick at a time.
979
+ * Draw whole rendered lines at one brightness level, for a block that fades in
980
+ * as a unit rather than word by word.
981
+ *
982
+ * The level is opened at the start of the line and reasserted after every SGR
983
+ * the line already carries, so the palette colors inside a card - the status
984
+ * glyph, the dim body rule, the bold tool name - are overridden while the card
985
+ * fades and come back on their own once it settles. Each line ends with the
986
+ * sequence that undoes what the level set - the terminal's own foreground, or
987
+ * its normal intensity in the two-level mode - so the level never leaks past
988
+ * the line it was applied to. Empty lines come back byte-identical, so
989
+ * the renderer leaves the blank rows around a card alone.
990
+ * @param lines - the rendered lines of the block.
991
+ * @param age - the brightness level to draw them at.
992
+ * @param style - the capability and the ramp.
993
+ * @param from - first line the level is applied to; the lines before it come
994
+ * back byte-identical, which is how a caller keeps rows the renderer can no
995
+ * longer repaint out of the fade.
996
+ * @returns the lines at that level; copies when the style writes no sequence
997
+ * for this age, which is what the `none` capability and a settled age yield.
998
+ */
999
+ function recolorLines(lines, age, style, from = 0) {
1000
+ const sgr = fadeSgr(style, age);
1001
+ if (sgr === "") return [...lines];
1002
+ const restore = restoreFor(style.capability);
1003
+ return lines.map((line, index) => index < from || line === "" ? line : `${sgr}${line.replace(SGR_SEQUENCE, (match) => match + sgr)}${restore}`);
1004
+ }
1005
+ /**
1006
+ * The tail of one streaming region - the visible text or the reasoning of one
1007
+ * message: the chunks young enough to be recolored, each ageing on the wall
1008
+ * clock from the moment it became visible.
932
1009
  *
933
1010
  * Deltas are split at word boundaries, which reads more smoothly than raw
934
1011
  * token edges. A delta that ends mid-word leaves that word open, and the next
935
1012
  * delta extends it rather than starting a second chunk, so the word keeps the
936
- * tick it first became visible on.
1013
+ * moment it first became visible.
937
1014
  *
938
- * Fast streams. While each of the last {@link FADE_FAST_WINDOW_TICKS}
939
- * completed ticks received at least one chunk, the stream outruns the ramp and
940
- * the effect turns off: the arriving chunk is not tracked and the whole tail is
941
- * flushed, so nothing already on screen darkens. The decision is recoverable:
942
- * one tick without an arrival ends it, and chunks appended afterwards fade
943
- * again. Recovery only affects new chunks, so it never re-darkens settled text.
1015
+ * Ages are read, never counted. A chunk's age is the elapsed time since it
1016
+ * became visible divided by `stepMs`, computed at the moment a render asks for
1017
+ * it, so a render triggered by a delta between two fade periods draws every
1018
+ * word at its own level and words do not move in lockstep. That is what makes
1019
+ * the trailing edge continuous rather than banded.
1020
+ *
1021
+ * Arrival rate changes the tail's length, never its depth: whatever the rate,
1022
+ * a chunk is at the foreground `steps * stepMs` after it appeared, so a fast
1023
+ * stream leaves a longer trail of brightening words and never a darker or a
1024
+ * lasting one. The tail is bounded in time, so it stays on at any rate.
944
1025
  */
945
1026
  var FadeTracker = class {
946
1027
  steps;
947
- fastWindowTicks;
948
- now = 0;
1028
+ stepMs;
1029
+ now;
949
1030
  chunks = [];
950
1031
  openChunk = void 0;
951
- arrivals = /* @__PURE__ */ new Set();
952
1032
  constructor(options) {
953
- this.steps = options?.steps ?? 5;
954
- this.fastWindowTicks = options?.fastWindowTicks ?? 10;
1033
+ this.steps = options.steps ?? 8;
1034
+ this.stepMs = options.stepMs ?? 33;
1035
+ this.now = options.now;
955
1036
  }
956
1037
  /**
957
1038
  * Take one stream event.
@@ -959,42 +1040,39 @@ var FadeTracker = class {
959
1040
  */
960
1041
  append(delta) {
961
1042
  if (delta === "") return;
962
- this.arrivals.add(this.now);
963
- if (this.isFastStream()) {
964
- this.flush();
965
- return;
966
- }
1043
+ const at = this.now();
967
1044
  const open = this.openChunk;
968
1045
  const text = open === void 0 ? delta : open.text + delta;
969
- const born = open === void 0 ? this.now : open.born;
1046
+ const bornAt = open === void 0 ? at : open.bornAt;
970
1047
  if (open !== void 0) this.chunks.pop();
971
1048
  this.openChunk = void 0;
972
1049
  const words = text.match(/\s*\S+\s*/g);
973
1050
  if (words === null) {
974
1051
  this.openChunk = {
975
1052
  text,
976
- born
1053
+ bornAt
977
1054
  };
978
1055
  this.chunks.push(this.openChunk);
979
1056
  return;
980
1057
  }
981
1058
  for (const [index, word] of words.entries()) this.chunks.push({
982
1059
  text: word,
983
- born: index === 0 ? born : this.now
1060
+ bornAt: index === 0 ? bornAt : at
984
1061
  });
985
1062
  if (!/\s$/.test(text)) this.openChunk = this.chunks.at(-1);
986
1063
  }
987
1064
  /**
988
- * Advance one tick and drop the chunks that reached `steps`.
989
- * @returns whether this tick changed a chunk's color, and so needs a repaint.
1065
+ * Drop the chunks that reached the step count, which the application runs
1066
+ * once per fade period so a settled chunk stops being matched against the
1067
+ * rendered lines.
1068
+ * @returns whether a chunk still draws below the last brightness level, and
1069
+ * so whether the tail keeps moving after this period.
990
1070
  */
991
1071
  tick() {
992
- const changing = this.needsRepaint();
993
- this.now += 1;
994
- this.chunks = this.chunks.filter((chunk) => this.now - chunk.born < this.steps);
1072
+ const moving = this.needsRepaint();
1073
+ this.chunks = this.chunks.filter((chunk) => this.ageOf(chunk) < this.steps);
995
1074
  if (this.openChunk !== void 0 && !this.chunks.includes(this.openChunk)) this.openChunk = void 0;
996
- for (const arrival of this.arrivals) if (arrival < this.now - this.fastWindowTicks) this.arrivals.delete(arrival);
997
- return changing;
1075
+ return moving;
998
1076
  }
999
1077
  /**
1000
1078
  * Whether the current frame still differs from the settled rendering.
@@ -1002,36 +1080,98 @@ var FadeTracker = class {
1002
1080
  * is the only reason to keep ticking.
1003
1081
  */
1004
1082
  needsRepaint() {
1005
- return this.chunks.some((chunk) => this.now - chunk.born < this.steps - 1);
1083
+ return this.chunks.some((chunk) => this.ageOf(chunk) < this.steps - 1);
1006
1084
  }
1007
1085
  /**
1008
1086
  * The tail {@link recolorTail} recolors.
1009
- * @returns one span per tracked chunk, oldest first, each with its current age.
1087
+ * @returns one span per tracked chunk, oldest first, each with the age it
1088
+ * carries at this instant.
1010
1089
  */
1011
1090
  spans() {
1012
1091
  return this.chunks.map((chunk) => ({
1013
1092
  text: chunk.text,
1014
- age: this.now - chunk.born
1093
+ age: this.ageOf(chunk)
1015
1094
  }));
1016
1095
  }
1017
1096
  /**
1018
1097
  * Drop the whole tail so every chunk drawn so far renders at the foreground,
1019
1098
  * and start tracking again from the next delta. The application calls this on
1020
- * a terminal width change and at stream end. The fast-stream window is kept:
1021
- * a resize says nothing about the arrival rate.
1099
+ * a terminal width change and at stream end.
1022
1100
  */
1023
1101
  flush() {
1024
1102
  this.chunks = [];
1025
1103
  this.openChunk = void 0;
1026
1104
  }
1027
1105
  /**
1028
- * The fast-stream test.
1029
- * @returns true while every completed tick of the window received at least one chunk.
1106
+ * The brightness level one chunk draws at right now.
1107
+ * @param chunk - the tracked chunk.
1108
+ * @returns levels elapsed since it became visible, 0 for a chunk younger than one level.
1109
+ */
1110
+ ageOf(chunk) {
1111
+ return Math.floor((this.now() - chunk.bornAt) / this.stepMs);
1112
+ }
1113
+ };
1114
+ /**
1115
+ * The age of one whole block that fades in as a unit - a tool card's rows,
1116
+ * which arrive complete rather than word by word.
1117
+ *
1118
+ * The last level is withheld the way the word tail withholds it: that level is
1119
+ * an assumed foreground, so a block one level below the end is handed back to
1120
+ * the terminal's own colors instead, and nothing jumps color as it settles.
1121
+ */
1122
+ var BlockFadeClock = class {
1123
+ options;
1124
+ constructor(options) {
1125
+ this.options = options;
1126
+ }
1127
+ /**
1128
+ * The brightness level the block draws at right now.
1129
+ * @returns the level, or undefined once the block reached the last drawn
1130
+ * level and renders in the colors the component itself produced.
1131
+ */
1132
+ age() {
1133
+ const { bornAt, stepMs, steps, now } = this.options;
1134
+ const age = Math.floor((now() - bornAt) / stepMs);
1135
+ return age < steps - 1 ? age : void 0;
1136
+ }
1137
+ /**
1138
+ * Whether this block still draws below the last brightness level.
1139
+ * @returns true while {@link BlockFadeClock.age} yields a level.
1030
1140
  */
1031
- isFastStream() {
1032
- let covered = 0;
1033
- for (let tick = this.now - this.fastWindowTicks; tick < this.now; tick += 1) if (this.arrivals.has(tick)) covered += 1;
1034
- return covered >= this.fastWindowTicks;
1141
+ needsRepaint() {
1142
+ return this.age() !== void 0;
1143
+ }
1144
+ };
1145
+ /**
1146
+ * The block fades running right now. The application arms its repaint while
1147
+ * any member still moves, so the registry holds only members that have not
1148
+ * settled: {@link FadeRegistry.tick} drops the settled ones once per period,
1149
+ * and a session change clears the whole set.
1150
+ */
1151
+ var FadeRegistry = class {
1152
+ members = /* @__PURE__ */ new Set();
1153
+ /**
1154
+ * Track one fade until it settles.
1155
+ * @param fade - the fade to follow.
1156
+ */
1157
+ add(fade) {
1158
+ this.members.add(fade);
1159
+ }
1160
+ /**
1161
+ * Whether any tracked fade still moves.
1162
+ * @returns true while one of them needs another repaint.
1163
+ */
1164
+ needsRepaint() {
1165
+ for (const member of this.members) if (member.needsRepaint()) return true;
1166
+ return false;
1167
+ }
1168
+ /** Forget the fades that settled, so a long session accumulates none of them. */
1169
+ tick() {
1170
+ for (const member of this.members) if (!member.needsRepaint()) this.members.delete(member);
1171
+ }
1172
+ /** Forget every tracked fade, which the application does when it draws another session. */
1173
+ clear() {
1174
+ this.members.clear();
1035
1175
  }
1036
1176
  };
1037
1177
  /**
@@ -1056,10 +1196,13 @@ var FadeTracker = class {
1056
1196
  * @param lines - the rendered lines of the streaming block, newest text last.
1057
1197
  * @param spans - the tail from {@link FadeTracker.spans}, oldest first.
1058
1198
  * @param style - the capability and the ramp.
1199
+ * @param from - first line the tail may recolor; the lines before it come back
1200
+ * byte-identical, which is how a caller keeps rows the renderer can no longer
1201
+ * repaint out of the fade.
1059
1202
  * @returns the lines with the tail recolored; lines the tail does not cover
1060
1203
  * are returned byte-identical, so the renderer leaves them alone.
1061
1204
  */
1062
- function recolorTail(lines, spans, style) {
1205
+ function recolorTail(lines, spans, style, from = 0) {
1063
1206
  if (style.capability === "none" || spans.length === 0 || lines.length === 0) return [...lines];
1064
1207
  const cells = cellsFromEnd(lines);
1065
1208
  const runs = [];
@@ -1077,7 +1220,7 @@ function recolorTail(lines, spans, style) {
1077
1220
  }
1078
1221
  const restore = restoreFor(style.capability);
1079
1222
  return lines.map((text, index) => {
1080
- const lineRuns = byLine.get(index);
1223
+ const lineRuns = index < from ? void 0 : byLine.get(index);
1081
1224
  return lineRuns === void 0 ? text : paintLine(text, lineRuns, restore);
1082
1225
  });
1083
1226
  }
@@ -1281,20 +1424,74 @@ function editorTheme(palette) {
1281
1424
  * Transcript components: one pi-tui component per rendered fact (a user
1282
1425
  * prompt, an assistant reply, a tool card, a notice). Each owns its display
1283
1426
  * state and re-renders from it at any width.
1427
+ *
1428
+ * The prompt, the reply, and the tool card are also the navigable blocks the
1429
+ * keyboard walks (`./navigation.ts`): each reports its sections as plain source
1430
+ * rows and draws a two-column gutter beside them while it holds the focus -
1431
+ * accented on the focused section's own lines and dim on the rest of the
1432
+ * block. The gutter narrows the width the block's content wraps at and is
1433
+ * prepended after any fade recoloring, so the fade keeps matching the block's
1434
+ * own text and the gutter's styling never enters that match.
1284
1435
  * @module @deepseek-ai/dsh-tui-app/blocks
1285
1436
  */
1437
+ /** Columns the focus gutter takes from the width a block's content wraps at. */
1438
+ const GUTTER_WIDTH = 2;
1439
+ /** The gutter beside the lines of a focused block that are not the focused section. */
1440
+ const BLOCK_GUTTER = "│ ";
1441
+ /** The gutter beside the lines of the focused section itself. */
1442
+ const PART_GUTTER = "┃ ";
1443
+ /** What the call section reports for a tool the model called with no arguments. */
1444
+ const NO_ARGUMENTS = "(no arguments)";
1445
+ /** What the result section reports for a tool that answered with nothing. */
1446
+ const NO_OUTPUT = "(no output)";
1447
+ /**
1448
+ * Prepend the focus gutter to every line of a block that holds the focus.
1449
+ * @param palette - the palette the gutter marks are styled with.
1450
+ * @param lines - the block's final rendered lines, fades already applied.
1451
+ * @param focused - whether the line at one index belongs to the focused section.
1452
+ * @returns the lines behind their gutter.
1453
+ */
1454
+ function withGutter(palette, lines, focused) {
1455
+ return lines.map((line, index) => `${focused(index) ? palette.accent(PART_GUTTER) : palette.dim(BLOCK_GUTTER)}${line}`);
1456
+ }
1286
1457
  /** A prompt the user submitted, drawn with a leading `›`. */
1287
1458
  var UserBlock = class {
1288
1459
  theme;
1289
1460
  text;
1290
- constructor(theme, text) {
1461
+ turn;
1462
+ navigable = true;
1463
+ blockKind = "user";
1464
+ /** The section drawn as focused; absent while the keyboard is elsewhere. */
1465
+ highlight;
1466
+ constructor(theme, text, turn) {
1291
1467
  this.theme = theme;
1292
1468
  this.text = text;
1469
+ this.turn = turn;
1470
+ }
1471
+ /**
1472
+ * The prompt as one navigable section.
1473
+ * @returns the single `user` part, carrying the submitted text.
1474
+ */
1475
+ parts() {
1476
+ return [{
1477
+ kind: "user",
1478
+ rows: this.text.split("\n")
1479
+ }];
1480
+ }
1481
+ /**
1482
+ * Draw this prompt with the focus gutter, or without it.
1483
+ * @param part - the focused section, or undefined to clear the mark.
1484
+ */
1485
+ setHighlight(part) {
1486
+ this.highlight = part;
1293
1487
  }
1294
1488
  invalidate() {}
1295
1489
  render(width) {
1296
1490
  const palette = this.theme.palette;
1297
- return ["", ...wrapTextWithAnsi(this.text, Math.max(1, width - 2)).map((line, index) => `${palette.accent(index === 0 ? "›" : " ")} ${palette.bold(line)}`)];
1491
+ const marked = this.highlight !== void 0;
1492
+ const inner = marked ? Math.max(1, width - GUTTER_WIDTH) : width;
1493
+ const lines = ["", ...wrapTextWithAnsi(this.text, Math.max(1, inner - 2)).map((line, index) => `${palette.accent(index === 0 ? "›" : " ")} ${palette.bold(line)}`)];
1494
+ return marked ? withGutter(palette, lines, () => true) : lines;
1298
1495
  }
1299
1496
  };
1300
1497
  /** A dim one-line notice about the session (a stopped turn, a command result, a model switch). */
@@ -1315,28 +1512,66 @@ var NoticeBlock = class {
1315
1512
  * An assistant reply: streamed reasoning above streamed Markdown text. The
1316
1513
  * durable `assistant/message` replaces both with the committed content.
1317
1514
  *
1318
- * A block that is streaming right now can carry a {@link FadeRender}, which
1319
- * draws its newest text dimmed and brightening. A block rebuilt from history
1320
- * carries none, and {@link AssistantBlock.commit} drops the one a streaming
1321
- * block had, so settled text is never recolored.
1515
+ * A block that is streaming right now can carry a {@link FadeRender} for its
1516
+ * text and another for its reasoning, each drawing that region's newest words
1517
+ * dimmed and brightening. A block rebuilt from history carries neither, and
1518
+ * {@link AssistantBlock.commit} drops the ones a streaming block had, so
1519
+ * settled text is never recolored.
1322
1520
  */
1323
1521
  var AssistantBlock = class {
1324
1522
  theme;
1523
+ turn;
1524
+ navigable = true;
1525
+ blockKind = "assistant";
1325
1526
  reasoning = "";
1326
1527
  text = "";
1327
1528
  interrupted = false;
1328
1529
  markdown;
1329
1530
  reasoningText;
1330
1531
  fade;
1331
- /** Width of the last render that drew a tail; absent before the first one. */
1532
+ /** Width of the last render that drew a text tail; absent before the first one. */
1332
1533
  fadeWidth;
1333
- constructor(theme) {
1534
+ reasoningFade;
1535
+ /** Width of the last render that drew a reasoning tail; absent before the first one. */
1536
+ reasoningFadeWidth;
1537
+ /** First line of this block either tail may still recolor. */
1538
+ repaintFloor = 0;
1539
+ /** The section drawn as focused; absent while the keyboard is elsewhere. */
1540
+ highlight;
1541
+ constructor(theme, turn) {
1334
1542
  this.theme = theme;
1543
+ this.turn = turn;
1335
1544
  const palette = theme.palette;
1336
1545
  this.markdown = new Markdown("", 0, 0, markdownTheme(palette));
1337
1546
  this.reasoningText = new Text("", 0, 0);
1338
1547
  }
1339
1548
  /**
1549
+ * The message as navigable sections, carrying the model's own text: the
1550
+ * reasoning as it was streamed or committed, and the reply as Markdown
1551
+ * source rather than the rendering the transcript draws.
1552
+ * @returns the reasoning part while the message has reasoning, then the
1553
+ * reply, which is present from the start and empty until text arrives.
1554
+ */
1555
+ parts() {
1556
+ const parts = [];
1557
+ if (this.reasoning.trim() !== "") parts.push({
1558
+ kind: "reasoning",
1559
+ rows: this.reasoning.split("\n")
1560
+ });
1561
+ parts.push({
1562
+ kind: "reply",
1563
+ rows: this.text.split("\n")
1564
+ });
1565
+ return parts;
1566
+ }
1567
+ /**
1568
+ * Draw this message with the focus gutter, or without it.
1569
+ * @param part - the focused section, or undefined to clear the mark.
1570
+ */
1571
+ setHighlight(part) {
1572
+ this.highlight = part;
1573
+ }
1574
+ /**
1340
1575
  * Draw this block's newest text through `fade` until it commits.
1341
1576
  * @param fade - the tail and drawing settings of the running stream.
1342
1577
  */
@@ -1344,6 +1579,26 @@ var AssistantBlock = class {
1344
1579
  this.fade = fade;
1345
1580
  }
1346
1581
  /**
1582
+ * Draw this block's newest reasoning through `fade` until it commits.
1583
+ * @param fade - the tail and drawing settings of the running stream.
1584
+ */
1585
+ setReasoningFade(fade) {
1586
+ this.reasoningFade = fade;
1587
+ }
1588
+ /**
1589
+ * Hand the rows the renderer can no longer repaint back to the colors this
1590
+ * block drew them in, so a tail that is still moving never rewrites them.
1591
+ * @param floor - this block's own first repaintable line; the application
1592
+ * raises it as the frame grows and never lowers it.
1593
+ * @returns whether the floor took rows away from a tail that is drawing
1594
+ * right now, and so whether the frame differs from the one just built.
1595
+ */
1596
+ setRepaintFloor(floor) {
1597
+ if (floor <= this.repaintFloor) return false;
1598
+ this.repaintFloor = floor;
1599
+ return drawnSpans(this.fade).length > 0 || drawnSpans(this.reasoningFade).length > 0;
1600
+ }
1601
+ /**
1347
1602
  * Append streamed visible text.
1348
1603
  * @param delta - the text delta.
1349
1604
  */
@@ -1370,6 +1625,7 @@ var AssistantBlock = class {
1370
1625
  this.reasoning = reasoning;
1371
1626
  this.interrupted = interrupted;
1372
1627
  this.fade = void 0;
1628
+ this.reasoningFade = void 0;
1373
1629
  this.markdown.setText(text);
1374
1630
  this.reasoningText.setText(this.theme.palette.dim(this.theme.palette.italic(reasoning.trimEnd())));
1375
1631
  }
@@ -1378,11 +1634,52 @@ var AssistantBlock = class {
1378
1634
  this.reasoningText.invalidate();
1379
1635
  }
1380
1636
  render(width) {
1637
+ const marked = this.highlight !== void 0;
1638
+ const inner = marked ? Math.max(1, width - GUTTER_WIDTH) : width;
1381
1639
  const lines = [""];
1382
- if (this.reasoning.trim() !== "") lines.push(...this.reasoningText.render(width), "");
1383
- if (this.text !== "") lines.push(...this.renderText(width));
1640
+ const reasoning = {
1641
+ from: 0,
1642
+ to: 0
1643
+ };
1644
+ const reply = {
1645
+ from: 0,
1646
+ to: 0
1647
+ };
1648
+ if (this.reasoning.trim() !== "") {
1649
+ reasoning.from = lines.length;
1650
+ lines.push(...this.renderReasoning(inner, lines.length));
1651
+ reasoning.to = lines.length;
1652
+ lines.push("");
1653
+ }
1654
+ if (this.text !== "") {
1655
+ reply.from = lines.length;
1656
+ lines.push(...this.renderText(inner, lines.length));
1657
+ reply.to = lines.length;
1658
+ }
1384
1659
  if (this.interrupted) lines.push(this.theme.palette.dim("[interrupted]"));
1385
- return lines;
1660
+ if (!marked) return lines;
1661
+ const section = this.highlight === "reasoning" ? reasoning : reply;
1662
+ return withGutter(this.theme.palette, lines, (index) => index >= section.from && index < section.to);
1663
+ }
1664
+ /**
1665
+ * The reasoning lines, with the streaming tail recolored.
1666
+ *
1667
+ * The ramp climbs towards the assumed terminal foreground, but the reasoning
1668
+ * draws inside the faint and italic sequences `appendReasoning` wrapped it
1669
+ * in, and a recolored run reasserts the styling in force at its start. The
1670
+ * tail therefore arrives at the dim foreground the settled reasoning carries,
1671
+ * not at the plain one.
1672
+ * @param width - the width the reasoning lays out in.
1673
+ * @param at - index of the region's first line in this block's render.
1674
+ * @returns the lines to draw.
1675
+ */
1676
+ renderReasoning(width, at) {
1677
+ const fade = this.reasoningFade;
1678
+ const lines = this.reasoningText.render(width);
1679
+ if (fade === void 0) return lines;
1680
+ if (this.reasoningFadeWidth !== void 0 && this.reasoningFadeWidth !== width) fade.flush();
1681
+ this.reasoningFadeWidth = width;
1682
+ return recolorTail(lines, drawnSpans(fade), fade.style(), this.repaintFloor - at);
1386
1683
  }
1387
1684
  /**
1388
1685
  * The Markdown lines, with the streaming tail recolored.
@@ -1391,36 +1688,102 @@ var AssistantBlock = class {
1391
1688
  * given, so it gets the Markdown lines alone: the reasoning above them and
1392
1689
  * any marker below them would put the newest chunk somewhere other than the
1393
1690
  * end and drop the whole tail to the plain foreground.
1394
- *
1395
- * Only chunks younger than `steps - 1` are handed over. The last ramp level
1396
- * is an assumed foreground - pi-tui reports the terminal background but not
1397
- * its foreground - so the oldest visible level is left to draw in the
1398
- * terminal's own foreground, which is also what the chunk draws in once it
1399
- * settles. No chunk can therefore jump color as it leaves the tail.
1400
1691
  * @param width - the width the Markdown lays out in.
1692
+ * @param at - index of the region's first line in this block's render.
1401
1693
  * @returns the lines to draw.
1402
1694
  */
1403
- renderText(width) {
1695
+ renderText(width, at) {
1404
1696
  const fade = this.fade;
1405
1697
  const lines = this.markdown.render(width);
1406
1698
  if (fade === void 0) return lines;
1407
1699
  if (this.fadeWidth !== void 0 && this.fadeWidth !== width) fade.flush();
1408
1700
  this.fadeWidth = width;
1409
- return recolorTail(lines, fade.spans().filter((span) => span.age < fade.steps - 1), fade.style());
1701
+ return recolorTail(lines, drawnSpans(fade), fade.style(), this.repaintFloor - at);
1410
1702
  }
1411
1703
  };
1412
- /** A tool call card: status glyph, tool name, headline, then a foldable body. */
1704
+ /**
1705
+ * A tool call card: status glyph, tool name, headline, then a foldable body.
1706
+ *
1707
+ * The card arrives in two pieces, and each fades in on its own: the header and
1708
+ * the call rows when the call is logged, the result rows when the tool
1709
+ * answers. A card rebuilt from history carries neither fade.
1710
+ */
1413
1711
  var ToolBlock = class {
1414
1712
  theme;
1415
1713
  name;
1416
1714
  call;
1715
+ turn;
1716
+ navigable = true;
1717
+ blockKind = "tool";
1417
1718
  status = "running";
1418
1719
  resultLines = [];
1419
1720
  expanded = false;
1420
- constructor(theme, name, call) {
1721
+ fade;
1722
+ resultFade;
1723
+ /** First line of this card either fade may still recolor. */
1724
+ repaintFloor = 0;
1725
+ /** The section drawn as focused; absent while the keyboard is elsewhere. */
1726
+ highlight;
1727
+ constructor(theme, name, call, turn) {
1421
1728
  this.theme = theme;
1422
1729
  this.name = name;
1423
1730
  this.call = call;
1731
+ this.turn = turn;
1732
+ }
1733
+ /** The card headline, as the section heading names this call. */
1734
+ get title() {
1735
+ return this.call.title;
1736
+ }
1737
+ /**
1738
+ * The card as navigable sections, carrying its untruncated rows whatever the
1739
+ * collapsed body shows.
1740
+ * @returns the call section, then the result section once the tool answered.
1741
+ */
1742
+ parts() {
1743
+ const call = [...this.call.title === "" ? [] : [this.call.title], ...this.call.lines];
1744
+ const parts = [{
1745
+ kind: "call",
1746
+ rows: call.length === 0 ? [NO_ARGUMENTS] : call
1747
+ }];
1748
+ if (this.status !== "running") parts.push({
1749
+ kind: "result",
1750
+ rows: this.resultLines.length === 0 ? [NO_OUTPUT] : this.resultLines
1751
+ });
1752
+ return parts;
1753
+ }
1754
+ /**
1755
+ * Draw this card with the focus gutter, or without it.
1756
+ * @param part - the focused section, or undefined to clear the mark.
1757
+ */
1758
+ setHighlight(part) {
1759
+ this.highlight = part;
1760
+ }
1761
+ /**
1762
+ * Draw the header and the call rows through `fade` until it settles.
1763
+ * @param fade - the level and drawing settings of this card's own fade.
1764
+ */
1765
+ setFade(fade) {
1766
+ this.fade = fade;
1767
+ }
1768
+ /**
1769
+ * Draw the result rows through `fade` until it settles.
1770
+ * @param fade - the level and drawing settings of the result's own fade.
1771
+ */
1772
+ setResultFade(fade) {
1773
+ this.resultFade = fade;
1774
+ }
1775
+ /**
1776
+ * Hand the rows the renderer can no longer repaint back to the colors this
1777
+ * card drew them in, so a fade that is still climbing never rewrites them.
1778
+ * @param floor - this card's own first repaintable line; the application
1779
+ * raises it as the frame grows and never lowers it.
1780
+ * @returns whether the floor took rows away from a fade that is drawing
1781
+ * right now, and so whether the frame differs from the one just built.
1782
+ */
1783
+ setRepaintFloor(floor) {
1784
+ if (floor <= this.repaintFloor) return false;
1785
+ this.repaintFloor = floor;
1786
+ return this.fade?.age() !== void 0 || this.resultFade?.age() !== void 0;
1424
1787
  }
1425
1788
  /**
1426
1789
  * Attach the result rows and settle the status.
@@ -1441,17 +1804,63 @@ var ToolBlock = class {
1441
1804
  invalidate() {}
1442
1805
  render(width) {
1443
1806
  const palette = this.theme.palette;
1807
+ const marked = this.highlight !== void 0;
1808
+ const outer = marked ? Math.max(1, width - GUTTER_WIDTH) : width;
1444
1809
  const header = `${this.status === "running" ? palette.warning("●") : this.status === "done" ? palette.success("●") : palette.error("●")} ${palette.bold(this.name)}${this.call.title === "" ? "" : ` ${palette.dim(this.call.title)}`}`;
1445
- const shown = previewLines([...this.call.lines, ...this.resultLines], this.theme.toolPreviewLines, this.expanded);
1446
- const inner = Math.max(1, width - 4);
1447
- const rows = shown.flatMap((line) => wrapTextWithAnsi(line, inner).map((part) => ` ${palette.dim("│")} ${part}`));
1448
- return [
1810
+ const body = [...this.call.lines, ...this.resultLines];
1811
+ const shown = previewLines(body, this.theme.toolPreviewLines, this.expanded);
1812
+ const callCount = Math.min(this.call.lines.length, shown.length);
1813
+ const inner = Math.max(1, outer - 4);
1814
+ const rows = (lines) => lines.flatMap((line) => wrapTextWithAnsi(line, inner).map((part) => ` ${palette.dim("│")} ${part}`));
1815
+ const callLines = [...wrapTextWithAnsi(header, outer), ...rows(shown.slice(0, callCount))];
1816
+ const resultLines = rows(shown.slice(callCount));
1817
+ const call = faded(callLines, this.fade, this.repaintFloor - 1);
1818
+ const result = faded(resultLines, this.resultFade, this.repaintFloor - 1 - callLines.length);
1819
+ const lines = [
1449
1820
  "",
1450
- ...wrapTextWithAnsi(header, width),
1451
- ...rows
1821
+ ...call,
1822
+ ...result
1452
1823
  ];
1824
+ if (!marked) return lines;
1825
+ const cut = !this.expanded && body.length > this.theme.toolPreviewLines;
1826
+ const sections = lines.length - (cut ? rows(shown.slice(-1)).length : 0);
1827
+ const section = this.highlight === "result" ? {
1828
+ from: 1 + callLines.length,
1829
+ to: lines.length
1830
+ } : {
1831
+ from: 1,
1832
+ to: 1 + callLines.length
1833
+ };
1834
+ return withGutter(palette, lines, (index) => index >= section.from && index < section.to && index < sections);
1453
1835
  }
1454
1836
  };
1837
+ /**
1838
+ * Draw one group of a card's rows at the level its fade reports.
1839
+ * @param lines - the group's final rendered lines.
1840
+ * @param fade - the group's fade; absent for a card that never faded.
1841
+ * @param from - first line of the group the fade may recolor.
1842
+ * @returns the lines at that level, or the lines themselves once the fade
1843
+ * settled or was never attached.
1844
+ */
1845
+ function faded(lines, fade, from) {
1846
+ if (fade === void 0) return lines;
1847
+ const age = fade.age();
1848
+ return age === void 0 ? lines : recolorLines(lines, age, fade.style(), from);
1849
+ }
1850
+ /**
1851
+ * The tail chunks one streaming region still draws below the terminal's own
1852
+ * foreground.
1853
+ *
1854
+ * The last ramp level is an assumed foreground - pi-tui reports the terminal
1855
+ * background but not its foreground - so a chunk that reached it is left to
1856
+ * draw in the terminal's own foreground, which is also what it draws in once it
1857
+ * leaves the tail. No chunk can therefore jump color as it settles.
1858
+ * @param fade - the region's fade; absent for a region that never faded.
1859
+ * @returns the spans to recolor, oldest first.
1860
+ */
1861
+ function drawnSpans(fade) {
1862
+ return fade === void 0 ? [] : fade.spans().filter((span) => span.age < fade.steps - 1);
1863
+ }
1455
1864
  //#endregion
1456
1865
  //#region lib/types/completion.js
1457
1866
  /**
@@ -1685,6 +2094,267 @@ async function exportSessionZip(ctx, sessionId, directory, signal) {
1685
2094
  return path;
1686
2095
  }
1687
2096
  //#endregion
2097
+ //#region lib/types/inspector.js
2098
+ /**
2099
+ * The docked inspector above the editor: while the keyboard walks the
2100
+ * transcript it shows the focused section in full - what it is, which part of
2101
+ * its block is held, and the start of that part's own rows.
2102
+ *
2103
+ * It is the focus indicator that is always on screen. A block is also marked
2104
+ * in place, but only while the renderer can still repaint its first line
2105
+ * (`./navigation.ts`), so the heading says when the marked block itself has
2106
+ * scrolled out of reach. Rows arrive as the block's source text with no fade
2107
+ * and no palette styling of their own, so a section that is still streaming
2108
+ * grows here as it arrives.
2109
+ * @module @deepseek-ai/dsh-tui-app/inspector
2110
+ */
2111
+ /** What separates two labels of the parts strip. */
2112
+ const SEPARATOR$3 = " · ";
2113
+ /** Brackets the parts strip, so the strip reads as one row of choices. */
2114
+ const STRIP_ENDS = ["‹ ", " ›"];
2115
+ /** What the heading appends while the focused block lies outside the repaint window. */
2116
+ const OFF_SCREEN = " · off screen";
2117
+ /** The keys the inspector answers, drawn dim under its rows. */
2118
+ const HINT = "↑ ↓ blocks · ← → parts · Enter page · Esc back";
2119
+ /**
2120
+ * Draw the focused section.
2121
+ * @param view - the section to show.
2122
+ * @param render - the palette, the row budget, and the width.
2123
+ * @returns the inspector's lines: a blank separator, the heading, the parts
2124
+ * strip when the block has more than one part, the folded rows, and the hints.
2125
+ */
2126
+ function renderInspector(view, render) {
2127
+ const { palette, width } = render;
2128
+ const suffix = view.highlighted ? "" : palette.dim(OFF_SCREEN);
2129
+ const lines = ["", `${palette.bold(palette.accent(view.heading))}${suffix}`];
2130
+ if (view.parts.length > 1) lines.push(partsStrip(view.parts, palette));
2131
+ const wrapped = view.rows.flatMap((row) => wrapTextWithAnsi(row, Math.max(1, width)));
2132
+ lines.push(...foldRows(wrapped, render.previewLines, (hidden) => palette.dim(`… ${String(hidden)} more row${hidden === 1 ? "" : "s"} · Enter opens the page`)));
2133
+ lines.push(palette.dim(HINT));
2134
+ return lines;
2135
+ }
2136
+ /**
2137
+ * Draw the parts of the focused block as one row of choices.
2138
+ * @param parts - the labels in reading order, the held one marked.
2139
+ * @param palette - the palette the labels are styled with.
2140
+ * @returns the strip line.
2141
+ */
2142
+ function partsStrip(parts, palette) {
2143
+ const labels = parts.map((part) => part.focused ? palette.accent(part.label) : palette.dim(part.label)).join(palette.dim(SEPARATOR$3));
2144
+ return `${palette.dim(STRIP_ENDS[0])}${labels}${palette.dim(STRIP_ENDS[1])}`;
2145
+ }
2146
+ /**
2147
+ * The inspector as a mounted component.
2148
+ *
2149
+ * The view is read once per render rather than pushed in, so a section that is
2150
+ * still streaming and a tool result that has just landed reach the screen with
2151
+ * the frame that draws them, without the application refreshing the pane.
2152
+ */
2153
+ var InspectorPane = class {
2154
+ view;
2155
+ settings;
2156
+ /**
2157
+ * @param view - reads the section to draw; undefined draws nothing at all,
2158
+ * which is what an unmounted moment and a transcript with no cursor yield.
2159
+ * @param settings - the palette and the row budget; the width arrives per render.
2160
+ */
2161
+ constructor(view, settings) {
2162
+ this.view = view;
2163
+ this.settings = settings;
2164
+ }
2165
+ invalidate() {}
2166
+ /**
2167
+ * Draw the focused section at `width`.
2168
+ * @param width - the total width the pane lays out in.
2169
+ * @returns the inspector's lines, or none when no section is focused.
2170
+ */
2171
+ render(width) {
2172
+ const view = this.view();
2173
+ return view === void 0 ? [] : renderInspector(view, {
2174
+ ...this.settings,
2175
+ width
2176
+ });
2177
+ }
2178
+ };
2179
+ //#endregion
2180
+ //#region lib/types/navigation.js
2181
+ /**
2182
+ * The transcript as a list of navigable sections.
2183
+ *
2184
+ * A conversation is drawn as one component per fact, and the keyboard walks it
2185
+ * in two directions: between blocks and, inside a block, between its parts -
2186
+ * the reasoning and the reply of a message, the call and the result of a tool.
2187
+ * This module turns the transcript container's children into that list and
2188
+ * moves one cursor over it. Everything here is plain data: no pi-tui, no
2189
+ * palette, no clock. The rows a part carries are the block's own source text,
2190
+ * so the page `Enter` opens shows what the model wrote rather than a rendering
2191
+ * of it.
2192
+ * @module @deepseek-ai/dsh-tui-app/navigation
2193
+ */
2194
+ /** What separates two facts inside one heading. */
2195
+ const SEPARATOR$2 = " · ";
2196
+ /**
2197
+ * Whether one transcript child is navigable.
2198
+ * @param child - a child of the transcript container.
2199
+ * @returns true when the child carries the navigable marker.
2200
+ */
2201
+ function isSectionSource(child) {
2202
+ return typeof child === "object" && child !== null && child.navigable === true;
2203
+ }
2204
+ /**
2205
+ * The navigable blocks of a transcript, in drawing order.
2206
+ * @param children - the transcript container's children.
2207
+ * @returns the children that expose sections; notices and printed rows are left out.
2208
+ */
2209
+ function navigableBlocks(children) {
2210
+ return children.filter(isSectionSource);
2211
+ }
2212
+ /**
2213
+ * Hold one index inside a list.
2214
+ * @param index - the wanted index.
2215
+ * @param length - the list length; at least 1.
2216
+ * @returns the index, moved to the nearest end when it falls outside.
2217
+ */
2218
+ function clampIndex(index, length) {
2219
+ return Math.max(0, Math.min(index, length - 1));
2220
+ }
2221
+ /**
2222
+ * How many parts one block carries.
2223
+ * @param blocks - the navigable blocks.
2224
+ * @param index - the block to count.
2225
+ * @returns the count, and 0 for an index past the list.
2226
+ */
2227
+ function partCount(blocks, index) {
2228
+ return blocks[index]?.parts().length ?? 0;
2229
+ }
2230
+ /**
2231
+ * Where the keyboard enters the transcript: the newest block, on its last
2232
+ * part, which is the most recent thing the session produced.
2233
+ * @param blocks - the navigable blocks.
2234
+ * @returns the cursor, or undefined when the transcript has nothing to inspect.
2235
+ */
2236
+ function enterNewest(blocks) {
2237
+ const block = blocks.length - 1;
2238
+ const parts = partCount(blocks, block);
2239
+ if (parts === 0) return void 0;
2240
+ return {
2241
+ block,
2242
+ part: parts - 1
2243
+ };
2244
+ }
2245
+ /**
2246
+ * Move to another block, landing on its last part.
2247
+ * @param cursor - where the focus sits.
2248
+ * @param step - 1 for the next block, -1 for the previous one.
2249
+ * @param blocks - the navigable blocks.
2250
+ * @returns the new cursor; the cursor itself at either end of the list.
2251
+ */
2252
+ function moveBlock(cursor, step, blocks) {
2253
+ const block = clampIndex(cursor.block + step, blocks.length);
2254
+ if (block === cursor.block) return cursor;
2255
+ return {
2256
+ block,
2257
+ part: Math.max(0, partCount(blocks, block) - 1)
2258
+ };
2259
+ }
2260
+ /**
2261
+ * Move between the parts of the held block.
2262
+ * @param cursor - where the focus sits.
2263
+ * @param step - 1 for the next part, -1 for the previous one.
2264
+ * @param blocks - the navigable blocks.
2265
+ * @returns the new cursor; the cursor itself at either end of the block.
2266
+ */
2267
+ function movePart(cursor, step, blocks) {
2268
+ const parts = partCount(blocks, cursor.block);
2269
+ if (parts === 0) return cursor;
2270
+ return {
2271
+ block: cursor.block,
2272
+ part: clampIndex(cursor.part + step, parts)
2273
+ };
2274
+ }
2275
+ /**
2276
+ * Put a remembered cursor back on the current transcript, which grew a block,
2277
+ * grew a part, or was replaced by another session since it was taken.
2278
+ * @param cursor - the remembered cursor.
2279
+ * @param blocks - the navigable blocks as they are now.
2280
+ * @returns the cursor inside the current list, or undefined when the
2281
+ * transcript has nothing to hold it.
2282
+ */
2283
+ function clampCursor(cursor, blocks) {
2284
+ if (blocks.length === 0) return void 0;
2285
+ const block = clampIndex(cursor.block, blocks.length);
2286
+ const parts = partCount(blocks, block);
2287
+ if (parts === 0) return void 0;
2288
+ return {
2289
+ block,
2290
+ part: clampIndex(cursor.part, parts)
2291
+ };
2292
+ }
2293
+ /**
2294
+ * The part the cursor holds.
2295
+ * @param cursor - where the focus sits.
2296
+ * @param blocks - the navigable blocks.
2297
+ * @returns the part, or undefined when the cursor points past the list.
2298
+ */
2299
+ function partAt(cursor, blocks) {
2300
+ return blocks[cursor.block]?.parts()[cursor.part];
2301
+ }
2302
+ /**
2303
+ * What the inspector and the parts strip call one section.
2304
+ * @param kind - the section.
2305
+ * @returns the label; the user's own prompt reads as `you`.
2306
+ */
2307
+ function partLabel(kind) {
2308
+ return kind === "user" ? "you" : kind;
2309
+ }
2310
+ /**
2311
+ * How a block names the held section after its position and turn.
2312
+ * @param block - the held block.
2313
+ * @param part - the held part.
2314
+ * @returns the naming facts, in heading order.
2315
+ */
2316
+ function subjectOf(block, part) {
2317
+ switch (block.blockKind) {
2318
+ case "user":
2319
+ case "assistant": return [partLabel(part.kind)];
2320
+ case "tool": return [block.title === "" ? block.name : `${block.name} ${block.title}`, block.parts().some((candidate) => candidate.kind === "result") ? partLabel(part.kind) : "running"];
2321
+ /* v8 ignore next -- closed-union exhaustiveness guard */
2322
+ default: return assertNever(block, "tui transcript block kind");
2323
+ }
2324
+ }
2325
+ /**
2326
+ * The inspector heading of the held section: its position in the transcript,
2327
+ * the turn it belongs to, and what the section is.
2328
+ * @param cursor - where the focus sits.
2329
+ * @param blocks - the navigable blocks.
2330
+ * @returns the heading, or an empty string when the cursor points past the list.
2331
+ */
2332
+ function sectionHeading(cursor, blocks) {
2333
+ const block = blocks[cursor.block];
2334
+ const part = partAt(cursor, blocks);
2335
+ if (block === void 0 || part === void 0) return "";
2336
+ return [
2337
+ `${String(cursor.block + 1)}/${String(blocks.length)}`,
2338
+ `turn ${String(block.turn)}`,
2339
+ ...subjectOf(block, part)
2340
+ ].join(SEPARATOR$2);
2341
+ }
2342
+ /**
2343
+ * The parts strip of the held block.
2344
+ * @param cursor - where the focus sits.
2345
+ * @param blocks - the navigable blocks.
2346
+ * @returns one label per part in reading order, the held one marked; empty
2347
+ * when the cursor points past the list.
2348
+ */
2349
+ function partLabels(cursor, blocks) {
2350
+ const block = blocks[cursor.block];
2351
+ if (block === void 0) return [];
2352
+ return block.parts().map((part, index) => ({
2353
+ label: partLabel(part.kind),
2354
+ focused: index === cursor.part
2355
+ }));
2356
+ }
2357
+ //#endregion
1688
2358
  //#region lib/types/todos.js
1689
2359
  /**
1690
2360
  * The agent's todo list as terminal rows: the status glyphs every todo surface
@@ -2088,9 +2758,9 @@ const WORKSPACE_LABEL_WIDTH = 24;
2088
2758
  /** The last two segments of a path, with the separators that precede them. */
2089
2759
  const PATH_TAIL = /[/\\][^/\\]+[/\\][^/\\]+$/u;
2090
2760
  /** The keys the focused bar answers, replacing the usual hints. */
2091
- const FOCUS_HINTS$1 = `← → select${SEPARATOR$1}Enter details${SEPARATOR$1}Esc back`;
2092
- /** What the unfocused hints advertise as the way into the bar. */
2093
- const ENTRY_HINT = "Shift+↑ status bar";
2761
+ const FOCUS_HINTS$1 = `← → select${SEPARATOR$1}↑ ↓ regions${SEPARATOR$1}Enter details${SEPARATOR$1}Esc back`;
2762
+ /** What the unfocused hints advertise as the two ways out of the editor. */
2763
+ const ENTRY_HINT = `Shift+↑ transcript${SEPARATOR$1}Shift+↓ status bar`;
2094
2764
  /** The command that prints every projection section at once. */
2095
2765
  const STATUS_COMMAND_ROW = "/status prints all of these sections";
2096
2766
  /**
@@ -2251,15 +2921,12 @@ const SELECT_MAX_VISIBLE = 8;
2251
2921
  /** Call-detail rows an approval shows before folding the rest into a count. */
2252
2922
  const APPROVAL_DETAIL_MAX_ROWS = 12;
2253
2923
  /**
2254
- * Cut detail rows to `max`, replacing the rest with a count.
2255
- * @param lines - the full rows.
2256
- * @param max - rows kept.
2257
- * @returns the rows to draw, with a trailing count of hidden rows when cut.
2924
+ * The trailing row an approval's folded call detail carries.
2925
+ * @param hidden - rows left out.
2926
+ * @returns the marker row.
2258
2927
  */
2259
- function foldRows(lines, max) {
2260
- if (lines.length <= max) return [...lines];
2261
- const hidden = lines.length - max;
2262
- return [...lines.slice(0, max), `… ${String(hidden)} more line${hidden === 1 ? "" : "s"}`];
2928
+ function approvalFoldMarker(hidden) {
2929
+ return `… ${String(hidden)} more line${hidden === 1 ? "" : "s"}`;
2263
2930
  }
2264
2931
  /** One settlement slot: the first value wins, later values are ignored. */
2265
2932
  var Settlement = class {
@@ -2378,7 +3045,7 @@ var ListPrompt = class {
2378
3045
  var ApprovalPrompt = class extends ListPrompt {
2379
3046
  constructor(palette, toolName, reason, detail = []) {
2380
3047
  const title = `${palette.warning("?")} ${palette.bold(`Allow ${toolName}?`)}`;
2381
- super(palette, reason === void 0 ? title : `${title}\n${palette.dim(reason)}`, foldRows(detail, APPROVAL_DETAIL_MAX_ROWS), [{
3048
+ super(palette, reason === void 0 ? title : `${title}\n${palette.dim(reason)}`, foldRows(detail, APPROVAL_DETAIL_MAX_ROWS, approvalFoldMarker), [{
2382
3049
  value: "allowed-once",
2383
3050
  label: "Allow once",
2384
3051
  description: "run this call"
@@ -2773,6 +3440,89 @@ var ModalQueue = class {
2773
3440
  }
2774
3441
  };
2775
3442
  //#endregion
3443
+ //#region lib/types/screen.js
3444
+ /**
3445
+ * The main screen this application renders through, and the repaint rule the
3446
+ * renderer imposes on everything drawn above the terminal's viewport.
3447
+ *
3448
+ * pi-tui's `TuiMainScreen` draws into the terminal's own scrollback. It
3449
+ * repaints differentially only the lines whose index is at or after
3450
+ * `previousViewportTop`, the top of the last written frame's viewport, and
3451
+ * falls back to a full redraw that writes `ESC[2J ESC[H ESC[3J` - which
3452
+ * discards the terminal's scrollback - as soon as a line above it changed.
3453
+ * `previousViewportTop` is a high-water mark: every frame raises it towards
3454
+ * `frameLines - terminal.rows` and a shorter frame never lowers it again, so a
3455
+ * line that once left the window stays out of reach until the renderer redraws
3456
+ * in full and resets it.
3457
+ *
3458
+ * Anything that redraws lines a component already produced - the streaming
3459
+ * fade, a card fade, the focus gutter - therefore asks {@link repaintFloor} how
3460
+ * far into a block the renderer can still follow it, and
3461
+ * {@link GuardedMainScreen} gives the application the one moment where that
3462
+ * question can be answered: after a frame was built and before it is written.
3463
+ * @module @deepseek-ai/dsh-tui-app/screen
3464
+ */
3465
+ /**
3466
+ * How many times one frame is offered to the guard before it is written. Two
3467
+ * passes let the guard answer for the geometry its own first pass produced -
3468
+ * a gutter rewraps the block it marks - and end on a frame the application's
3469
+ * own record of what is marked describes.
3470
+ */
3471
+ const SETTLE_PASSES = 2;
3472
+ /**
3473
+ * The first line of one block the renderer can still repaint.
3474
+ * @param start - index of the block's first line in the frame.
3475
+ * @param viewportTop - the frame's first repaintable line, as
3476
+ * {@link GuardedMainScreen} hands it to its guard.
3477
+ * @returns the block's own first repaintable line index, 0 while the whole
3478
+ * block lies inside the window.
3479
+ */
3480
+ function repaintFloor(start, viewportTop) {
3481
+ return Math.max(0, viewportTop - start);
3482
+ }
3483
+ /**
3484
+ * The main screen that settles each frame between building it and writing it.
3485
+ *
3486
+ * The guard is handed the frame's first repaintable line: the higher of the
3487
+ * renderer's own `previousViewportTop` and the boundary the frame just built
3488
+ * imposes once it is written. The first term is what the renderer judges this
3489
+ * write against. The second keeps every decision one frame ahead of that
3490
+ * judgement, so what the guard changes today it can still change back
3491
+ * tomorrow. Settling before the write also means a fade reaches the terminal
3492
+ * already settled instead of changing in the next frame, by which time its
3493
+ * lines may sit above the window.
3494
+ */
3495
+ var GuardedMainScreen = class extends TuiMainScreen {
3496
+ guard;
3497
+ /**
3498
+ * @param terminal - the terminal the tree renders into.
3499
+ * @param showHardwareCursor - whether the terminal's own cursor is the caret.
3500
+ * @param guard - given the frame's first repaintable line and the width it
3501
+ * was built at, applies what that geometry decides and reports whether it
3502
+ * changed any line; run at most {@link SETTLE_PASSES} times per frame, each
3503
+ * `true` on a frame that is built again.
3504
+ */
3505
+ constructor(terminal, showHardwareCursor, guard) {
3506
+ super(terminal, showHardwareCursor);
3507
+ this.guard = guard;
3508
+ }
3509
+ /**
3510
+ * Build the frame, let the guard settle what its geometry decides, and build
3511
+ * it again for every pass that changed a line.
3512
+ * @param width - the total width the tree lays out in.
3513
+ * @returns the frame to write, settled against its own geometry.
3514
+ */
3515
+ render(width) {
3516
+ const previousTop = this.captureRenderState().previousViewportTop;
3517
+ let lines = super.render(width);
3518
+ for (let pass = 0; pass < SETTLE_PASSES; pass += 1) {
3519
+ if (!this.guard(Math.max(previousTop, lines.length - this.terminal.rows), width)) break;
3520
+ lines = super.render(width);
3521
+ }
3522
+ return lines;
3523
+ }
3524
+ };
3525
+ //#endregion
2776
3526
  //#region lib/types/sessions.js
2777
3527
  /**
2778
3528
  * Session facts the terminal shows and switches between: the persisted
@@ -2908,7 +3658,10 @@ function renderSubagentPanel(view, render) {
2908
3658
  * the editor it keeps two docked regions the keyboard can take over — the
2909
3659
  * subagent panel and the status bar — and one repeating tick advances their
2910
3660
  * elapsed counters and re-reads a stale subagent listing. A second tick, at
2911
- * its own period, brightens the text of the message streaming right now.
3661
+ * its own period, brightens the text of the message streaming right now and
3662
+ * the tool cards that just landed, each only as far up the frame as the
3663
+ * renderer repaints without discarding the terminal's scrollback
3664
+ * (`./screen.ts`).
2912
3665
  * @module @deepseek-ai/dsh-tui-app/app
2913
3666
  */
2914
3667
  /** A second Ctrl+C inside this window quits. */
@@ -3086,6 +3839,8 @@ var TuiApp = class {
3086
3839
  statusSlot = new Container();
3087
3840
  loader;
3088
3841
  modalSlot = new Container();
3842
+ /** Draws the focused transcript section, and nothing at all while the keyboard is elsewhere. */
3843
+ inspector;
3089
3844
  editor;
3090
3845
  /** Holds {@link panel} exactly while the bound session has subagent rows. */
3091
3846
  panelSlot = new Container();
@@ -3108,8 +3863,16 @@ var TuiApp = class {
3108
3863
  home = homedir();
3109
3864
  /** The segments of the last footer draw, in bar order. */
3110
3865
  segments = [];
3111
- /** Which docked region owns the keyboard. */
3866
+ /** Which region owns the keyboard. */
3112
3867
  focus = "editor";
3868
+ /**
3869
+ * Where the transcript focus sits, kept across a page and a return to the
3870
+ * editor. A cursor the current transcript cannot place names nothing, which
3871
+ * is what a session change leaves behind.
3872
+ */
3873
+ cursor;
3874
+ /** The section drawn with the focus gutter right now; set only by {@link TuiApp.settleFrame}. */
3875
+ highlighted;
3113
3876
  /** The segment the status bar holds; read only while the bar has focus. */
3114
3877
  barSelection = FIRST_FOOTER_SEGMENT;
3115
3878
  /** The descendant listing the last reconcile produced, in pre-order. */
@@ -3128,8 +3891,14 @@ var TuiApp = class {
3128
3891
  ticker;
3129
3892
  /** Disposer of the fade tick while it is armed. */
3130
3893
  fadeTicker;
3131
- /** The tail of the message streaming right now; absent between messages. */
3132
- fadeTail;
3894
+ /** The visible-text tail of the message streaming right now; absent between messages. */
3895
+ textTail;
3896
+ /** The reasoning tail of the message streaming right now; absent between messages. */
3897
+ reasoningTail;
3898
+ /** The card fades running right now; each drops itself once it settles. */
3899
+ blockFades = new FadeRegistry();
3900
+ /** Set while {@link TuiApp.bind} replays a session's history, which draws its cards settled. */
3901
+ replaying = false;
3133
3902
  /** Whether this terminal draws a ramp at all, decided once at start. */
3134
3903
  fading = false;
3135
3904
  /** How streamed text is drawn; `none` until the background query settles. */
@@ -3154,8 +3923,12 @@ var TuiApp = class {
3154
3923
  palette,
3155
3924
  toolPreviewLines: deps.toolPreviewLines
3156
3925
  };
3157
- this.tui = new TuiMainScreen(deps.terminal, true);
3926
+ this.tui = new GuardedMainScreen(deps.terminal, true, (viewportTop, width) => this.settleFrame(viewportTop, width));
3158
3927
  this.header = new Text("", 0, 0);
3928
+ this.inspector = new InspectorPane(() => this.inspectorView(), {
3929
+ palette,
3930
+ previewLines: deps.focusPreviewLines
3931
+ });
3159
3932
  this.loader = new Loader(this.tui, palette.accent, palette.dim, "thinking");
3160
3933
  this.loader.stop();
3161
3934
  this.editor = new BarCursorEditor(this.tui, editorTheme(palette), { paddingX: 1 });
@@ -3173,15 +3946,17 @@ var TuiApp = class {
3173
3946
  slot: this.modalSlot,
3174
3947
  focusAfter: this.editor
3175
3948
  });
3176
- for (const child of [
3949
+ const tree = [
3177
3950
  this.header,
3178
3951
  this.chat,
3179
3952
  this.statusSlot,
3180
3953
  this.modalSlot,
3954
+ this.inspector,
3181
3955
  this.editor,
3182
3956
  this.panelSlot,
3183
3957
  this.footer
3184
- ]) this.tui.addChild(child);
3958
+ ];
3959
+ for (const child of tree) this.tui.addChild(child);
3185
3960
  }
3186
3961
  get agent() {
3187
3962
  return this.bound.agent;
@@ -3229,10 +4004,11 @@ var TuiApp = class {
3229
4004
  if (initialPrompt !== void 0) this.submit(initialPrompt);
3230
4005
  }
3231
4006
  /**
3232
- * Decide whether streamed text fades at all, once per run, from the
3233
- * palette, the environment, and the reduced-motion preference. A terminal
3234
- * that draws no ramp tracks no tail and arms no fade tick, so streaming
3235
- * costs there exactly what it did before the effect existed.
4007
+ * Decide whether streamed text and tool cards fade at all, once per run,
4008
+ * from the palette, the environment, and the reduced-motion preference. A
4009
+ * terminal that draws no ramp tracks no tail, attaches no card fade, and
4010
+ * arms no fade tick, so streaming costs there exactly what it does with the
4011
+ * effect switched off.
3236
4012
  */
3237
4013
  startFade() {
3238
4014
  const capability = resolveFadeCapability({
@@ -3280,6 +4056,8 @@ var TuiApp = class {
3280
4056
  /** Draw `next` as the terminal's session: clear the transcript and replay its history. */
3281
4057
  bind(next) {
3282
4058
  this.bound = next;
4059
+ this.focusEditor();
4060
+ this.highlighted = void 0;
3283
4061
  this.chat.clear();
3284
4062
  this.toolBlocks.clear();
3285
4063
  this.toolArguments.clear();
@@ -3289,6 +4067,7 @@ var TuiApp = class {
3289
4067
  this.turnStartedAt = void 0;
3290
4068
  this.streaming = void 0;
3291
4069
  this.endFade();
4070
+ this.blockFades.clear();
3292
4071
  this.usage = EMPTY_USAGE;
3293
4072
  this.pending = [];
3294
4073
  this.subagentEntries = [];
@@ -3296,7 +4075,9 @@ var TuiApp = class {
3296
4075
  this.listingFailure = void 0;
3297
4076
  this.subagentsStale = false;
3298
4077
  this.setWorking(next.agent.status === "running");
4078
+ this.replaying = true;
3299
4079
  for (const event of next.history) this.onSessionEvent(next.agent.session, event);
4080
+ this.replaying = false;
3300
4081
  this.refreshHeader();
3301
4082
  this.refreshFooter();
3302
4083
  this.refreshSubagentPanel();
@@ -3778,12 +4559,13 @@ var TuiApp = class {
3778
4559
  }
3779
4560
  if (this.focus === "bar") return this.onStatusBarKey(data);
3780
4561
  if (this.focus === "panel") return this.onPanelKey(data);
4562
+ if (this.focus === "transcript") return this.onTranscriptKey(data);
3781
4563
  if (matchesKey(data, "shift+up")) {
3782
- this.focusBar();
4564
+ this.focusTranscript();
3783
4565
  return { consume: true };
3784
4566
  }
3785
- if (matchesKey(data, "shift+down") && this.panelView.rows.length > 0) {
3786
- this.focusRegion("panel");
4567
+ if (matchesKey(data, "shift+down")) {
4568
+ this.focusBelowEditor();
3787
4569
  return { consume: true };
3788
4570
  }
3789
4571
  if (matchesKey(data, "escape") && !this.editor.isShowingAutocomplete()) {
@@ -3812,18 +4594,38 @@ var TuiApp = class {
3812
4594
  }
3813
4595
  }
3814
4596
  /**
3815
- * Answer one key while the status bar holds focus. Every key is consumed
3816
- * here, so nothing typed at the bar reaches the editor.
4597
+ * Answer the keys every non-editor region answers the same way: `Escape`
4598
+ * hands the keyboard back to the editor, `Shift+Up` names the conversation,
4599
+ * and `Shift+Down` the status bar.
3817
4600
  * @param data - the raw key bytes.
3818
- * @returns the consume marker the input listener returns.
4601
+ * @returns the consume marker when the key named a region, `undefined` when the focused region owns the key.
3819
4602
  */
3820
- onStatusBarKey(data) {
3821
- if (matchesKey(data, "escape") || matchesKey(data, "shift+down")) {
4603
+ onRegionKey(data) {
4604
+ if (matchesKey(data, "escape")) {
3822
4605
  this.focusEditor();
3823
4606
  return { consume: true };
3824
4607
  }
3825
4608
  if (matchesKey(data, "shift+up")) {
3826
- this.focusRegion(this.panelView.rows.length > 0 ? "panel" : "editor");
4609
+ this.focusTranscript();
4610
+ return { consume: true };
4611
+ }
4612
+ if (matchesKey(data, "shift+down")) {
4613
+ this.focusBar();
4614
+ return { consume: true };
4615
+ }
4616
+ }
4617
+ /**
4618
+ * Answer one key while the status bar holds focus. Every key is consumed
4619
+ * here, so nothing typed at the bar reaches the editor.
4620
+ * @param data - the raw key bytes.
4621
+ * @returns the consume marker the input listener returns.
4622
+ */
4623
+ onStatusBarKey(data) {
4624
+ const region = this.onRegionKey(data);
4625
+ if (region !== void 0) return region;
4626
+ if (matchesKey(data, "up")) {
4627
+ if (this.panelView.rows.length > 0) this.focusPanel(this.panelView.rows.length - 1);
4628
+ else this.focusTranscript();
3827
4629
  return { consume: true };
3828
4630
  }
3829
4631
  if (matchesKey(data, "left") || matchesKey(data, "shift+tab")) {
@@ -3838,27 +4640,60 @@ var TuiApp = class {
3838
4640
  return { consume: true };
3839
4641
  }
3840
4642
  /**
3841
- * Answer one key while the subagent panel holds focus. Every key is
3842
- * consumed here; `Ctrl+C` and `Ctrl+D` never reach this far, keeping their
3843
- * global meaning.
4643
+ * Answer one key while the transcript holds focus. Every key is consumed
4644
+ * here, so nothing typed while reading the conversation reaches the editor;
4645
+ * `Ctrl+C` and `Ctrl+D` never reach this far and keep their global meaning.
3844
4646
  * @param data - the raw key bytes.
3845
4647
  * @returns the consume marker the input listener returns.
3846
4648
  */
3847
- onPanelKey(data) {
3848
- if (matchesKey(data, "escape") || matchesKey(data, "shift+up")) {
4649
+ onTranscriptKey(data) {
4650
+ const region = this.onRegionKey(data);
4651
+ if (region !== void 0) return region;
4652
+ const section = this.focusedSection();
4653
+ /* v8 ignore next 5 -- only a session change empties the transcript, and it hands the keyboard back first */
4654
+ if (section === void 0) {
3849
4655
  this.focusEditor();
3850
4656
  return { consume: true };
3851
4657
  }
3852
- if (matchesKey(data, "shift+down")) {
3853
- this.focusBar();
4658
+ const { cursor, blocks } = section;
4659
+ if (matchesKey(data, "up")) {
4660
+ this.moveCursor(moveBlock(cursor, -1, blocks));
4661
+ return { consume: true };
4662
+ }
4663
+ if (matchesKey(data, "down")) {
4664
+ if (cursor.block === blocks.length - 1) this.focusBelowEditor();
4665
+ else this.moveCursor(moveBlock(cursor, 1, blocks));
3854
4666
  return { consume: true };
3855
4667
  }
4668
+ if (matchesKey(data, "left")) {
4669
+ this.moveCursor(movePart(cursor, -1, blocks));
4670
+ return { consume: true };
4671
+ }
4672
+ if (matchesKey(data, "right")) {
4673
+ this.moveCursor(movePart(cursor, 1, blocks));
4674
+ return { consume: true };
4675
+ }
4676
+ if (matchesKey(data, "enter")) this.openSection(section);
4677
+ return { consume: true };
4678
+ }
4679
+ /**
4680
+ * Answer one key while the subagent panel holds focus. Every key is
4681
+ * consumed here; `Ctrl+C` and `Ctrl+D` never reach this far, keeping their
4682
+ * global meaning.
4683
+ * @param data - the raw key bytes.
4684
+ * @returns the consume marker the input listener returns.
4685
+ */
4686
+ onPanelKey(data) {
4687
+ const region = this.onRegionKey(data);
4688
+ if (region !== void 0) return region;
3856
4689
  if (matchesKey(data, "up")) {
3857
- this.movePanel(-1);
4690
+ if (this.panelSelectionIndex(this.panelView.rows) === 0) this.focusTranscript();
4691
+ else this.movePanel(-1);
3858
4692
  return { consume: true };
3859
4693
  }
3860
4694
  if (matchesKey(data, "down")) {
3861
- this.movePanel(1);
4695
+ if (this.panelSelectionIndex(this.panelView.rows) === this.panelView.rows.length - 1) this.focusBar();
4696
+ else this.movePanel(1);
3862
4697
  return { consume: true };
3863
4698
  }
3864
4699
  if (matchesKey(data, "enter")) this.navigate("subagent details", () => this.openPanelRow());
@@ -3903,16 +4738,15 @@ var TuiApp = class {
3903
4738
  this.refreshFooter();
3904
4739
  }
3905
4740
  /**
3906
- * Move the panel's selection, wrapping at both ends of the drawn rows. The
4741
+ * Move the panel's selection, stopping at both ends of the drawn rows. The
3907
4742
  * rows behind a `+<n> more` row are not selectable; `/subagents` walks the
3908
4743
  * complete tree.
3909
4744
  * @param step - 1 for the next row, -1 for the previous one.
3910
4745
  */
3911
4746
  movePanel(step) {
3912
4747
  const rows = this.panelView.rows;
3913
- const count = rows.length;
3914
- const next = rows[(this.panelSelectionIndex(rows) + step + count) % count];
3915
- /* v8 ignore next -- the wrapped index stays inside the panel's own rows */
4748
+ const next = rows[Math.max(0, Math.min(this.panelSelectionIndex(rows) + step, rows.length - 1))];
4749
+ /* v8 ignore next -- the clamped index stays inside the panel's own rows */
3916
4750
  if (next !== void 0) this.panelSelection = next.id;
3917
4751
  this.refreshSubagentPanel();
3918
4752
  }
@@ -3921,13 +4755,103 @@ var TuiApp = class {
3921
4755
  this.barSelection = FIRST_FOOTER_SEGMENT;
3922
4756
  this.focusRegion("bar");
3923
4757
  }
4758
+ /**
4759
+ * Give the keyboard to the subagent panel on one of its rows.
4760
+ * @param index - the row the selection lands on.
4761
+ */
4762
+ focusPanel(index) {
4763
+ this.panelSelection = this.panelView.rows[index]?.id;
4764
+ this.focusRegion("panel");
4765
+ }
4766
+ /**
4767
+ * Give the keyboard to the region under the editor: the subagent panel's
4768
+ * first row while the panel is drawn, and the status bar otherwise.
4769
+ */
4770
+ focusBelowEditor() {
4771
+ if (this.panelView.rows.length > 0) this.focusPanel(0);
4772
+ else this.focusBar();
4773
+ }
4774
+ /**
4775
+ * Give the keyboard to the transcript on its newest block. A session with
4776
+ * nothing to inspect yet says so and leaves the keyboard where it was.
4777
+ */
4778
+ focusTranscript() {
4779
+ const cursor = enterNewest(navigableBlocks(this.chat.children));
4780
+ if (cursor === void 0) {
4781
+ this.notice("nothing in the transcript to inspect yet");
4782
+ return;
4783
+ }
4784
+ this.cursor = cursor;
4785
+ this.focusRegion("transcript");
4786
+ this.tui.requestRender();
4787
+ }
4788
+ /**
4789
+ * Put the transcript focus on another section.
4790
+ * @param cursor - where the focus moves to.
4791
+ */
4792
+ moveCursor(cursor) {
4793
+ this.cursor = cursor;
4794
+ this.tui.requestRender();
4795
+ }
4796
+ /**
4797
+ * Read the remembered cursor against the transcript as it is drawn right
4798
+ * now: it grew parts and blocks since the cursor was taken, and a session
4799
+ * change replaced the blocks it named altogether. The cursor itself is left
4800
+ * alone, so a page and a trip through the editor come back to the same
4801
+ * section; {@link TuiApp.focusTranscript} replaces it.
4802
+ * @returns the section the cursor names, or undefined when the transcript
4803
+ * has nothing to hold it.
4804
+ */
4805
+ focusedSection() {
4806
+ const blocks = navigableBlocks(this.chat.children);
4807
+ const cursor = this.cursor === void 0 ? void 0 : clampCursor(this.cursor, blocks);
4808
+ if (cursor === void 0) return void 0;
4809
+ const block = blocks[cursor.block];
4810
+ const part = block?.parts()[cursor.part];
4811
+ /* v8 ignore next -- clampCursor settles on a block that carries the part it names */
4812
+ if (block === void 0 || part === void 0) return void 0;
4813
+ return {
4814
+ cursor,
4815
+ blocks,
4816
+ block,
4817
+ part
4818
+ };
4819
+ }
4820
+ /**
4821
+ * Open the focused section as a read-only page and come back to it.
4822
+ * @param section - the focused section.
4823
+ */
4824
+ openSection(section) {
4825
+ const { cursor, blocks } = section;
4826
+ const prompt = new DetailPrompt(this.deps.palette, sectionHeading(cursor, blocks), section.part.rows);
4827
+ this.navigate("section", async () => {
4828
+ await this.showModal(prompt);
4829
+ this.focusRegion("transcript");
4830
+ });
4831
+ }
4832
+ /**
4833
+ * The focused section as the docked inspector draws it.
4834
+ * @returns the view, or undefined while the keyboard is not in the
4835
+ * transcript, which draws no inspector at all.
4836
+ */
4837
+ inspectorView() {
4838
+ const section = this.focusedSection();
4839
+ if (section === void 0 || this.focus !== "transcript") return void 0;
4840
+ const { cursor, blocks } = section;
4841
+ return {
4842
+ heading: sectionHeading(cursor, blocks),
4843
+ parts: partLabels(cursor, blocks),
4844
+ rows: section.part.rows,
4845
+ highlighted: this.highlighted !== void 0
4846
+ };
4847
+ }
3924
4848
  /** Hand the keyboard back to the editor; a no-op while the editor already has it. */
3925
4849
  focusEditor() {
3926
4850
  this.focusRegion("editor");
3927
4851
  }
3928
4852
  /**
3929
- * Move the keyboard between the docked regions and redraw both of them, so
3930
- * the region losing focus stops drawing its selection.
4853
+ * Move the keyboard between the regions and redraw the docked ones, so the
4854
+ * region losing focus stops drawing its selection.
3931
4855
  * @param region - the region that takes the keyboard.
3932
4856
  */
3933
4857
  focusRegion(region) {
@@ -3986,7 +4910,7 @@ var TuiApp = class {
3986
4910
  });
3987
4911
  this.submittedIds.add(message.id);
3988
4912
  const shown = attachments.length === 0 ? text : `${text}\n${attachments.map((attachment) => `[${attachment.block.type}: ${attachment.name}]`).join(" ")}`;
3989
- this.chat.addChild(new UserBlock(this.theme, shown));
4913
+ this.chat.addChild(new UserBlock(this.theme, shown, this.turn));
3990
4914
  if (agent.status !== "running") agent.followup(message);
3991
4915
  else if (mode === "steer") {
3992
4916
  agent.steer(message);
@@ -4093,8 +5017,9 @@ var TuiApp = class {
4093
5017
  "@ completes paths and sessions (workspace, ../, ~/, absolute) · / completes commands",
4094
5018
  "Esc stops the running turn · Ctrl+O expands or collapses tool output",
4095
5019
  "Shift+Tab cycles the current model's reasoning effort for the next request",
4096
- "Shift+Up focuses the status bar: ← → select a fact, Enter shows its details, Esc returns to the input",
4097
- "Shift+Up again focuses the subagent panel while it is drawn: select a child, Enter shows its session",
5020
+ "Shift+Up focuses the transcript, Shift+Down the subagent panel or the status bar",
5021
+ "Then move between blocks, panel rows, and the bar; move between a block's parts or the bar's segments",
5022
+ "Enter opens the focused section or segment, Esc returns to the input",
4098
5023
  "Ctrl+C clears the input (twice quits) · Ctrl+D on an empty input quits"
4099
5024
  ];
4100
5025
  this.chat.addChild(new Text([
@@ -4535,7 +5460,7 @@ var TuiApp = class {
4535
5460
  }
4536
5461
  streamingBlock() {
4537
5462
  if (this.streaming === void 0) {
4538
- this.streaming = new AssistantBlock(this.theme);
5463
+ this.streaming = new AssistantBlock(this.theme, this.turn);
4539
5464
  this.chat.addChild(this.streaming);
4540
5465
  }
4541
5466
  return this.streaming;
@@ -4550,48 +5475,109 @@ var TuiApp = class {
4550
5475
  const block = this.streamingBlock();
4551
5476
  block.appendText(delta);
4552
5477
  if (!this.fading) return;
4553
- if (this.fadeTail === void 0) {
4554
- this.fadeTail = new FadeTracker({ steps: this.deps.fadeSteps });
4555
- block.setFade({
4556
- spans: () => this.fadeTail?.spans() ?? [],
4557
- style: () => this.fadeStyle,
4558
- steps: this.deps.fadeSteps,
4559
- flush: () => {
4560
- this.flushFade();
4561
- }
4562
- });
5478
+ if (this.textTail === void 0) {
5479
+ this.textTail = this.createTail();
5480
+ block.setFade(this.tailRender(() => this.textTail));
5481
+ }
5482
+ this.textTail.append(delta);
5483
+ this.updateFadeTicker();
5484
+ }
5485
+ /**
5486
+ * Take one reasoning delta, which fades in on its own tail: the reasoning
5487
+ * and the visible text of one message stream at different times and each
5488
+ * brightens from the moment its own words appeared.
5489
+ * @param delta - the streamed reasoning delta.
5490
+ */
5491
+ appendStreamedReasoning(delta) {
5492
+ const block = this.streamingBlock();
5493
+ block.appendReasoning(delta);
5494
+ if (!this.fading) return;
5495
+ if (this.reasoningTail === void 0) {
5496
+ this.reasoningTail = this.createTail();
5497
+ block.setReasoningFade(this.tailRender(() => this.reasoningTail));
4563
5498
  }
4564
- this.fadeTail.append(delta);
5499
+ this.reasoningTail.append(delta);
4565
5500
  this.updateFadeTicker();
4566
5501
  }
4567
5502
  /**
4568
- * Settle what the current block has drawn and stop tracking it: the tail
4569
- * goes, so its text renders at the terminal's foreground from the next
4570
- * render on, and no fade tick stays armed between messages.
5503
+ * Start tracking one streaming region against the application's clock.
5504
+ * @returns the tail deltas are appended to.
5505
+ */
5506
+ createTail() {
5507
+ return new FadeTracker({
5508
+ steps: this.deps.fadeSteps,
5509
+ stepMs: this.deps.fadeStepMs,
5510
+ now: () => this.deps.now()
5511
+ });
5512
+ }
5513
+ /**
5514
+ * The live view of one tail the streaming block reads per render.
5515
+ * @param tail - reads the field the tail is held in, so a render after the
5516
+ * message settled sees the empty tail rather than the one it was given.
5517
+ * @returns the spans, the drawing settings, and the width-change flush.
5518
+ */
5519
+ tailRender(tail) {
5520
+ return {
5521
+ spans: () => tail()?.spans() ?? [],
5522
+ style: () => this.fadeStyle,
5523
+ steps: this.deps.fadeSteps,
5524
+ flush: () => {
5525
+ this.flushFade();
5526
+ }
5527
+ };
5528
+ }
5529
+ /**
5530
+ * Fade one group of a card's rows in from the terminal background, from the
5531
+ * instant the log carried them. A terminal that draws no ramp and a session
5532
+ * being replayed fade nothing.
5533
+ * @param attach - hands the fade to the block that draws those rows.
5534
+ */
5535
+ fadeBlock(attach) {
5536
+ if (!this.fading || this.replaying) return;
5537
+ const clock = new BlockFadeClock({
5538
+ bornAt: this.deps.now(),
5539
+ stepMs: this.deps.fadeStepMs,
5540
+ steps: this.deps.fadeSteps,
5541
+ now: () => this.deps.now()
5542
+ });
5543
+ this.blockFades.add(clock);
5544
+ attach({
5545
+ age: () => clock.age(),
5546
+ style: () => this.fadeStyle
5547
+ });
5548
+ this.updateFadeTicker();
5549
+ }
5550
+ /**
5551
+ * Settle what the current message has drawn and stop tracking it: both
5552
+ * tails go, so their text renders at the terminal's foreground from the next
5553
+ * render on. Card fades are left to expire on their own clock, because a
5554
+ * card that landed at the end of a turn keeps brightening after it.
4571
5555
  */
4572
5556
  endFade() {
4573
- this.fadeTail = void 0;
5557
+ this.textTail = void 0;
5558
+ this.reasoningTail = void 0;
4574
5559
  this.updateFadeTicker();
4575
5560
  }
4576
5561
  /**
4577
- * Settle what is drawn while the message keeps streaming, which the block
4578
- * asks for after a width change. The tracker stays, so what it knows about
4579
- * the arrival rate survives a resize and only the tail is dropped.
5562
+ * Settle what is drawn while the message keeps streaming, which a block asks
5563
+ * for after a width change: the columns either tail was matched against no
5564
+ * longer describe the rewrapped lines.
4580
5565
  */
4581
5566
  flushFade() {
4582
- this.fadeTail?.flush();
5567
+ this.textTail?.flush();
5568
+ this.reasoningTail?.flush();
4583
5569
  this.updateFadeTicker();
4584
5570
  }
4585
5571
  /**
4586
- * Arm the fade tick while a chunk is still below the last brightness level
4587
- * and disarm it otherwise, so a session that is not streaming runs no fade
4588
- * timer. Exactly one runs at a time, and a stopped app runs none.
5572
+ * Arm the fade tick while streamed text or a card still draws below the last
5573
+ * brightness level and disarm it otherwise, so a session that is not
5574
+ * streaming runs no fade timer. Exactly one runs at a time, and a stopped
5575
+ * app runs none.
4589
5576
  */
4590
5577
  updateFadeTicker() {
4591
- const tail = this.fadeTail;
4592
- if (!this.stopped && tail !== void 0 && tail.needsRepaint()) {
5578
+ if (!this.stopped && this.fadesMoving()) {
4593
5579
  this.fadeTicker ??= this.deps.tick(() => {
4594
- this.onFadeTick(tail);
5580
+ this.onFadeTick();
4595
5581
  }, this.deps.fadeStepMs);
4596
5582
  return;
4597
5583
  }
@@ -4601,14 +5587,64 @@ var TuiApp = class {
4601
5587
  ticker();
4602
5588
  }
4603
5589
  /**
4604
- * One fade period over the tail the tick was armed for. Every change to the
4605
- * tail runs {@link TuiApp.updateFadeTicker} again, so an armed period always
4606
- * holds that same tail and always moves a chunk's color; the render request
4607
- * redraws the lines it moved, and the disarm check follows the ageing.
4608
- * @param tail - the tail this tick was armed for.
5590
+ * Whether anything the application fades still draws below the last
5591
+ * brightness level.
5592
+ * @returns true while a tail or a card fade keeps changing what is drawn.
5593
+ */
5594
+ fadesMoving() {
5595
+ return this.textTail?.needsRepaint() === true || this.reasoningTail?.needsRepaint() === true || this.blockFades.needsRepaint();
5596
+ }
5597
+ /**
5598
+ * Apply everything the frame's geometry decides, on the frame that was just
5599
+ * built and before it is written.
5600
+ *
5601
+ * Two effects redraw lines a component already produced and are therefore
5602
+ * held to the renderer's repaint window ({@link GuardedMainScreen}): a
5603
+ * running fade, which is told each block's own first repaintable line, and
5604
+ * the focus gutter, which a block gains or loses only while its first line
5605
+ * lies inside the window. A focused block above the window simply stays
5606
+ * unmarked, and the inspector reports that instead.
5607
+ * @param viewportTop - the frame's first repaintable line.
5608
+ * @param width - the width it was built at.
5609
+ * @returns whether anything changed a line, so the frame is built again
5610
+ * before it is written.
5611
+ */
5612
+ settleFrame(viewportTop, width) {
5613
+ const section = this.focusedSection();
5614
+ const wanted = section !== void 0 && this.focus === "transcript" ? {
5615
+ block: section.block,
5616
+ part: section.part.kind
5617
+ } : void 0;
5618
+ const fades = this.fadesMoving();
5619
+ if (!fades && wanted === void 0 && this.highlighted === void 0) return false;
5620
+ let start = this.header.render(width).length;
5621
+ let wantedStart;
5622
+ let changed = false;
5623
+ for (const child of this.chat.children) {
5624
+ if (isSectionSource(child) && child === wanted?.block) wantedStart = start;
5625
+ if (fades && (child instanceof AssistantBlock || child instanceof ToolBlock)) {
5626
+ if (child.setRepaintFloor(repaintFloor(start, viewportTop))) changed = true;
5627
+ }
5628
+ start += child.render(width).length;
5629
+ }
5630
+ const target = wantedStart !== void 0 && repaintFloor(wantedStart, viewportTop) === 0 ? wanted : void 0;
5631
+ const current = this.highlighted;
5632
+ if (current?.block === target?.block && current?.part === target?.part) return changed;
5633
+ current?.block.setHighlight(void 0);
5634
+ target?.block.setHighlight(target.part);
5635
+ this.highlighted = target;
5636
+ return true;
5637
+ }
5638
+ /**
5639
+ * One fade period: every tracked fade drops what settled since the last one,
5640
+ * the render request redraws the levels the clock moved, and the disarm
5641
+ * check follows. Ages come from the clock, so this period repaints the
5642
+ * levels the elapsed time asks for however long the period itself ran.
4609
5643
  */
4610
- onFadeTick(tail) {
4611
- tail.tick();
5644
+ onFadeTick() {
5645
+ this.textTail?.tick();
5646
+ this.reasoningTail?.tick();
5647
+ this.blockFades.tick();
4612
5648
  this.tui.requestRender();
4613
5649
  this.updateFadeTicker();
4614
5650
  }
@@ -4625,7 +5661,7 @@ var TuiApp = class {
4625
5661
  if (chunk.text !== "") this.appendStreamedText(chunk.text);
4626
5662
  break;
4627
5663
  case "reasoning-delta":
4628
- if (chunk.text !== "") this.streamingBlock().appendReasoning(chunk.text);
5664
+ if (chunk.text !== "") this.appendStreamedReasoning(chunk.text);
4629
5665
  break;
4630
5666
  case "tool-call-delta":
4631
5667
  if (chunk.name !== void 0) this.loader.setMessage(`calling ${chunk.name}`);
@@ -4684,10 +5720,13 @@ var TuiApp = class {
4684
5720
  const { callId, name, arguments: argumentsJson } = event.data;
4685
5721
  const args = parseArguments(argumentsJson);
4686
5722
  this.toolArguments.set(callId, args);
4687
- const block = new ToolBlock(this.theme, name, toolCallText(argumentsJson, this.presentCall(name, args)));
5723
+ const block = new ToolBlock(this.theme, name, toolCallText(argumentsJson, this.presentCall(name, args)), this.turn);
4688
5724
  block.setExpanded(this.toolsExpanded);
4689
5725
  this.toolBlocks.set(callId, block);
4690
5726
  this.chat.addChild(block);
5727
+ this.fadeBlock((fade) => {
5728
+ block.setFade(fade);
5729
+ });
4691
5730
  break;
4692
5731
  }
4693
5732
  case "tool/result": {
@@ -4697,6 +5736,9 @@ var TuiApp = class {
4697
5736
  const isError = result.isError === true;
4698
5737
  const view = this.presentResult(block.name, this.toolArguments.get(result.toolCallId), result.content, isError, event.data.meta);
4699
5738
  block.setResult(toolResultLines(view, result.content), isError);
5739
+ this.fadeBlock((fade) => {
5740
+ block.setResultFade(fade);
5741
+ });
4700
5742
  this.loader.setMessage("thinking");
4701
5743
  break;
4702
5744
  }
@@ -4755,7 +5797,7 @@ var TuiApp = class {
4755
5797
  if (source.kind === "user") {
4756
5798
  if (this.submittedIds.has(message.id)) return;
4757
5799
  const attachments = message.content.filter((block) => block.type !== "text").map((block) => `[${block.type}]`);
4758
- this.chat.addChild(new UserBlock(this.theme, [contentText(message.content), ...attachments].filter((part) => part !== "").join("\n")));
5800
+ this.chat.addChild(new UserBlock(this.theme, [contentText(message.content), ...attachments].filter((part) => part !== "").join("\n"), this.turn));
4759
5801
  return;
4760
5802
  }
4761
5803
  if (source.kind === "plugin" && source.form === "notice") this.chat.addChild(new NoticeBlock(this.theme, source.summary));
@@ -4864,9 +5906,10 @@ const Config = z.object({
4864
5906
  prompt: z.string(),
4865
5907
  resume: z.string(),
4866
5908
  toolPreviewLines: z.natural().min(1).default(8),
5909
+ focusPreviewLines: z.natural().min(1).default(12),
4867
5910
  liveRefreshMs: z.natural().min(100).default(1e3),
4868
- streamFadeSteps: z.natural().min(2).default(5),
4869
- streamFadeStepMs: z.natural().min(16).default(40),
5911
+ streamFadeSteps: z.natural().min(2).default(8),
5912
+ streamFadeStepMs: z.natural().min(16).default(33),
4870
5913
  reducedMotion: z.boolean().default(false),
4871
5914
  openBrowser: z.boolean().default(true)
4872
5915
  });
@@ -5039,6 +6082,7 @@ async function run(ctx, config, host) {
5039
6082
  terminal: host.createTerminal(),
5040
6083
  palette: createPalette(host.color),
5041
6084
  toolPreviewLines: config.toolPreviewLines,
6085
+ focusPreviewLines: config.focusPreviewLines,
5042
6086
  liveRefreshMs: config.liveRefreshMs,
5043
6087
  fadeSteps: config.streamFadeSteps,
5044
6088
  fadeStepMs: config.streamFadeStepMs,