@xp266/dshtui 0.1.4 → 0.1.6

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/bin/dshtui.js CHANGED
@@ -43,6 +43,12 @@ function runDsh(args) {
43
43
  : spawnSync('dsh', args, { stdio: 'inherit' })
44
44
  }
45
45
 
46
+ function probeDsh() {
47
+ return windows
48
+ ? spawnSync(['dsh', '--version'].map(quote).join(' '), { stdio: 'ignore', shell: true })
49
+ : spawnSync('dsh', ['--version'], { stdio: 'ignore' })
50
+ }
51
+
46
52
  function readJson(path) {
47
53
  try {
48
54
  return JSON.parse(readFileSync(path, 'utf8'))
@@ -107,7 +113,7 @@ function resolveProfile() {
107
113
 
108
114
  const profile = resolveProfile()
109
115
  const profileDir = join(profilesDir, profile)
110
- const dshProbe = runDsh(['--version'])
116
+ const dshProbe = probeDsh()
111
117
  if (dshProbe.error !== undefined || dshProbe.status !== 0) {
112
118
  fail('the dsh CLI was not found on PATH; install it with: npm install -g @deepseek-ai/dsh')
113
119
  }
@@ -1,2 +1,2 @@
1
- import { $ as ToolCallPresentation, A as MessageViewContribution, At as TuiToolsFace, B as PointerSession, Bt as MouseEventData, C as MarkdownBlockContext, Ct as TuiPaletteFace, D as MessageRendererContribution, Dt as TuiSessionSummary, E as MarkdownInlineContribution, Et as TuiServicesFace, F as PasteContext, Ft as WindowCommandSpec, G as SelectionExtractContext, H as PrismGrammarContribution, Ht as MessageKind, I as PasteHandlerContribution, It as WindowContribution, J as SpecialFieldCreateInput, K as SelectionTransformerContribution, L as PasteResult, Lt as WindowHandle, M as OverlayContribution, Mt as TuiWindowsFace, N as POINTER_PLUGIN_ORDER, Nt as WidgetDef, O as MessageRendererResult, Ot as TuiStartupFace, P as PaintArgs, Pt as WidgetKeyApi, Q as ThemePaletteContribution, R as PointerEventFrame, Rt as WindowProps, S as KeyBindingContribution, St as TuiMarkdownFace, T as MarkdownInlineContext, Tt as TuiSelectionFace, U as SelectionClipboardContribution, Ut as LineSelection, V as PointerUiContext, Vt as MouseEventType, W as SelectionDomainContribution, Wt as PendingImage, X as SpecialFieldStyle, Y as SpecialFieldFactory, Z as StatusLineContribution, _ as InputStatusContext, _t as TuiHomeLogoFace, a as CommandDef, at as ToolViewContribution, b as InteractionPanelComponentProps, bt as TuiKey, c as CustomMessageSeed, ct as TuiChatFace, d as DiffLine, dt as TuiCommandsFace, et as ToolReadLine, f as DiffLineKind, ft as TuiComposerFace, g as HomeLogoContribution, gt as TuiHintFace, h as HintMatcherContribution, ht as TuiFieldsFace, i as ClipboardBackendContribution, it as ToolViewCallContext, j as MessageViewRender, jt as TuiWidgetsFace, k as MessageViewContext, kt as TuiStartupSink, l as DialogItemKinds, lt as TuiChromeFace, m as HintEntry, mt as TuiExtensionPoint, n as ClickActions, nt as ToolResultPresentation, o as ComposerInsertSpec, ot as ToolViewDiff, p as HintArgsContribution, pt as TuiContentFace, q as SpecialFieldContribution, r as ClickHit, rt as ToolResultView, s as CustomMessage, st as ToolViewResultContext, t as ChatNodeDefinition, tt as ToolReadView, u as DiffHunk, ut as TuiClipboardFace, v as InputStatusContribution, vt as TuiInteractionPanelsFace, w as MarkdownBlockContribution, wt as TuiPointerFace, x as InteractionPanelContribution, xt as TuiKeymapFace, y as InputStatusPart, yt as TuiInteractionsFace, z as PointerHandlerContribution, zt as ScrollSnapshot } from "../index-DqnaSXqP.mjs";
1
+ import { $ as ToolCallPresentation, A as MessageViewContribution, At as TuiToolsFace, B as PointerSession, Bt as MouseEventData, C as MarkdownBlockContext, Ct as TuiPaletteFace, D as MessageRendererContribution, Dt as TuiSessionSummary, E as MarkdownInlineContribution, Et as TuiServicesFace, F as PasteContext, Ft as WindowCommandSpec, G as SelectionExtractContext, H as PrismGrammarContribution, Ht as MessageKind, I as PasteHandlerContribution, It as WindowContribution, J as SpecialFieldCreateInput, K as SelectionTransformerContribution, L as PasteResult, Lt as WindowHandle, M as OverlayContribution, Mt as TuiWindowsFace, N as POINTER_PLUGIN_ORDER, Nt as WidgetDef, O as MessageRendererResult, Ot as TuiStartupFace, P as PaintArgs, Pt as WidgetKeyApi, Q as ThemePaletteContribution, R as PointerEventFrame, Rt as WindowProps, S as KeyBindingContribution, St as TuiMarkdownFace, T as MarkdownInlineContext, Tt as TuiSelectionFace, U as SelectionClipboardContribution, Ut as LineSelection, V as PointerUiContext, Vt as MouseEventType, W as SelectionDomainContribution, Wt as PendingImage, X as SpecialFieldStyle, Y as SpecialFieldFactory, Z as StatusLineContribution, _ as InputStatusContext, _t as TuiHomeLogoFace, a as CommandDef, at as ToolViewContribution, b as InteractionPanelComponentProps, bt as TuiKey, c as CustomMessageSeed, ct as TuiChatFace, d as DiffLine, dt as TuiCommandsFace, et as ToolReadLine, f as DiffLineKind, ft as TuiComposerFace, g as HomeLogoContribution, gt as TuiHintFace, h as HintMatcherContribution, ht as TuiFieldsFace, i as ClipboardBackendContribution, it as ToolViewCallContext, j as MessageViewRender, jt as TuiWidgetsFace, k as MessageViewContext, kt as TuiStartupSink, l as DialogItemKinds, lt as TuiChromeFace, m as HintEntry, mt as TuiExtensionPoint, n as ClickActions, nt as ToolResultPresentation, o as ComposerInsertSpec, ot as ToolViewDiff, p as HintArgsContribution, pt as TuiContentFace, q as SpecialFieldContribution, r as ClickHit, rt as ToolResultView, s as CustomMessage, st as ToolViewResultContext, t as ChatNodeDefinition, tt as ToolReadView, u as DiffHunk, ut as TuiClipboardFace, v as InputStatusContribution, vt as TuiInteractionPanelsFace, w as MarkdownBlockContribution, wt as TuiPointerFace, x as InteractionPanelContribution, xt as TuiKeymapFace, y as InputStatusPart, yt as TuiInteractionsFace, z as PointerHandlerContribution, zt as ScrollSnapshot } from "../index-D4_nxuuR.mjs";
2
2
  export { ChatNodeDefinition, ClickActions, ClickHit, ClipboardBackendContribution, CommandDef, ComposerInsertSpec, CustomMessage, CustomMessageSeed, DialogItemKinds, DiffHunk, DiffLine, DiffLineKind, HintArgsContribution, HintEntry, HintMatcherContribution, HomeLogoContribution, InputStatusContext, InputStatusContribution, InputStatusPart, InteractionPanelComponentProps, InteractionPanelContribution, KeyBindingContribution, type LineSelection, MarkdownBlockContext, MarkdownBlockContribution, MarkdownInlineContext, MarkdownInlineContribution, type MessageKind, MessageRendererContribution, MessageRendererResult, MessageViewContext, MessageViewContribution, MessageViewRender, type MouseEventData, type MouseEventType, OverlayContribution, POINTER_PLUGIN_ORDER, PaintArgs, PasteContext, PasteHandlerContribution, PasteResult, type PendingImage, PointerEventFrame, PointerHandlerContribution, PointerSession, PointerUiContext, PrismGrammarContribution, type ScrollSnapshot, SelectionClipboardContribution, SelectionDomainContribution, SelectionExtractContext, SelectionTransformerContribution, SpecialFieldContribution, SpecialFieldCreateInput, SpecialFieldFactory, SpecialFieldStyle, StatusLineContribution, ThemePaletteContribution, ToolCallPresentation, ToolReadLine, ToolReadView, ToolResultPresentation, ToolResultView, ToolViewCallContext, ToolViewContribution, ToolViewDiff, ToolViewResultContext, TuiChatFace, TuiChromeFace, TuiClipboardFace, TuiCommandsFace, TuiComposerFace, TuiContentFace, TuiExtensionPoint, TuiFieldsFace, TuiHintFace, TuiHomeLogoFace, TuiInteractionPanelsFace, TuiInteractionsFace, TuiKey, TuiKeymapFace, TuiMarkdownFace, TuiPaletteFace, TuiPointerFace, TuiSelectionFace, TuiServicesFace, TuiSessionSummary, TuiStartupFace, TuiStartupSink, TuiToolsFace, TuiWidgetsFace, TuiWindowsFace, WidgetDef, WidgetKeyApi, WindowCommandSpec, WindowContribution, WindowHandle, WindowProps };
package/lib/dialog.d.mts CHANGED
@@ -1,4 +1,4 @@
1
- import { Gt as Segment, Lt as WindowHandle, Nt as WidgetDef, l as DialogItemKinds } from "./index-DqnaSXqP.mjs";
1
+ import { Gt as Segment, Lt as WindowHandle, Nt as WidgetDef, l as DialogItemKinds } from "./index-D4_nxuuR.mjs";
2
2
  import { ReactNode, Ref } from "react";
3
3
  //#region src/ui/dialog/items.d.ts
4
4
  interface DialogRow {
package/lib/dialog.mjs CHANGED
@@ -1,3 +1,3 @@
1
1
  import "./prod-react-Dzof4qJx.mjs";
2
- import { I as panelAnchorRow, c as asTextItem, i as Dialog, l as registerWidget, n as panelLegend, o as rowHeight, r as CloseGuardContext, s as wrapStatusLines, t as PanelSurface } from "./surface-DdxzIgIY.mjs";
2
+ import { I as panelAnchorRow, c as asTextItem, i as Dialog, l as registerWidget, n as panelLegend, o as rowHeight, r as CloseGuardContext, s as wrapStatusLines, t as PanelSurface } from "./surface-B7w3R_kh.mjs";
3
3
  export { CloseGuardContext, Dialog, PanelSurface, asTextItem, panelAnchorRow, panelLegend, registerWidget, rowHeight, wrapStatusLines };
@@ -124,18 +124,8 @@ interface CompactionMessage {
124
124
  error?: string;
125
125
  streaming?: boolean;
126
126
  }
127
- interface HomeLogoMessage {
128
- kind: 'home-logo';
129
- id: string;
130
- /** Artwork lines after culling; empty when the terminal cannot fit the logo. */
131
- lines: readonly string[];
132
- /** One gradient color per artwork line, aligned with `lines`. */
133
- colors: readonly string[];
134
- /** Version banner lines rendered under the artwork inside the same bubble. */
135
- info: readonly string[];
136
- }
137
- type MessageKind = 'bubble' | 'collapsible' | 'tool-card' | 'compaction' | 'custom' | 'home-logo';
138
- type Message = BubbleMessage | CollapsibleMessage | ToolCardMessage | CompactionMessage | CustomMessage | HomeLogoMessage;
127
+ type MessageKind = 'bubble' | 'collapsible' | 'tool-card' | 'compaction' | 'custom';
128
+ type Message = BubbleMessage | CollapsibleMessage | ToolCardMessage | CompactionMessage | CustomMessage;
139
129
  //#endregion
140
130
  //#region src/terminal/mouse.d.ts
141
131
  type MouseEventType = 'down' | 'up' | 'drag' | 'move' | 'scroll';
@@ -439,7 +429,7 @@ interface InputStatusContribution {
439
429
  interface HomeLogoContribution {
440
430
  id: string;
441
431
  order?: number;
442
- /** Logo artwork rendered as the first chat-styled bubble on the home page, one string per terminal row. */
432
+ /** Logo artwork centered on the empty home page, one string per terminal row. */
443
433
  lines: readonly string[];
444
434
  }
445
435
  interface TuiHomeLogoFace {
package/lib/index.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  import "./prod-react-Dzof4qJx.mjs";
2
- import { $ as errorText, A as SelectableText, At as setColorLevel, B as inputFrameTop, C as caretNonceText, Ct as registerPalette, D as markKeymapConsumed, Dt as themeMode, E as isKeyConsumed, Et as subscribePalettes, F as messageBackgroundWidth, Ft as warn, G as wrapSegments, H as glyphs, I as panelAnchorRow, J as clampFocusRow, K as chromeSelectionText, L as panelContains, M as Region, Mt as configureLogs, N as hintSpan, Nt as error, O as createMouseController, Ot as keyedRegistry, P as inputContentContains, Pt as installCrashHandlers, Q as colToCharIndex, R as scrollbarColumn, S as caretNonceColor, St as permissionModeInfo, T as beginKeyEvent, Tt as setThemeMode, U as mergeRuns, V as inputStatusRow, W as segmentsKey, X as toScreenSelection, Y as selectedRange, Z as charWidth, _ as editDeleteWordRight, _t as releaseFields, a as DIALOG_MAX_HEIGHT, at as wrapLines, b as writeCursorShape, bt as COLORS, ct as fieldExpandOf, d as editCursorLeft, dt as hasFieldChar, et as lineBreaks, f as editCursorRight, ft as hasFieldSlots, g as editDeleteWordLeft, gt as registerFieldKind, h as editDelete, ht as linesChipLabel, i as Dialog, it as truncate, j as SelectionContext, jt as env, k as isMouseResidue, kt as colorLevel, l as registerWidget, lt as fieldSlotOf, m as editCursorWordRight, mt as isFieldChar, n as panelLegend, nt as segmentGraphemes, ot as allocateField, p as editCursorWordLeft, pt as imageChipLabel, q as sliceByColumns, r as CloseGuardContext, rt as textWidth, st as charactersChipLabel, t as PanelSurface, tt as locToPoint, u as editBackspace, ut as fieldStyleOf, v as editInsert, vt as releaseUnreferenced, w as useCaret, wt as replacePalettes, x as caretNonceBold, xt as paletteColor, y as CapText, yt as specialFieldFactory, z as hintBlockTop } from "./surface-DdxzIgIY.mjs";
2
+ import { $ as errorText, A as SelectableText, At as setColorLevel, B as inputFrameTop, C as caretNonceText, Ct as registerPalette, D as markKeymapConsumed, Dt as themeMode, E as isKeyConsumed, Et as subscribePalettes, F as messageBackgroundWidth, Ft as warn, G as wrapSegments, H as glyphs, I as panelAnchorRow, J as clampFocusRow, K as chromeSelectionText, L as panelContains, M as Region, Mt as configureLogs, N as hintSpan, Nt as error, O as createMouseController, Ot as keyedRegistry, P as inputContentContains, Pt as installCrashHandlers, Q as colToCharIndex, R as scrollbarColumn, S as caretNonceColor, St as permissionModeInfo, T as beginKeyEvent, Tt as setThemeMode, U as mergeRuns, V as inputStatusRow, W as segmentsKey, X as toScreenSelection, Y as selectedRange, Z as charWidth, _ as editDeleteWordRight, _t as releaseFields, a as DIALOG_MAX_HEIGHT, at as wrapLines, b as writeCursorShape, bt as COLORS, ct as fieldExpandOf, d as editCursorLeft, dt as hasFieldChar, et as lineBreaks, f as editCursorRight, ft as hasFieldSlots, g as editDeleteWordLeft, gt as registerFieldKind, h as editDelete, ht as linesChipLabel, i as Dialog, it as truncate, j as SelectionContext, jt as env, k as isMouseResidue, kt as colorLevel, l as registerWidget, lt as fieldSlotOf, m as editCursorWordRight, mt as isFieldChar, n as panelLegend, nt as segmentGraphemes, ot as allocateField, p as editCursorWordLeft, pt as imageChipLabel, q as sliceByColumns, r as CloseGuardContext, rt as textWidth, st as charactersChipLabel, t as PanelSurface, tt as locToPoint, u as editBackspace, ut as fieldStyleOf, v as editInsert, vt as releaseUnreferenced, w as useCaret, wt as replacePalettes, x as caretNonceBold, xt as paletteColor, y as CapText, yt as specialFieldFactory, z as hintBlockTop } from "./surface-B7w3R_kh.mjs";
3
3
  import { createRequire } from "node:module";
4
4
  import { readFileSync, statSync } from "node:fs";
5
5
  import { join, resolve } from "node:path";
@@ -2499,23 +2499,6 @@ function renderBody(message, width) {
2499
2499
  bgs: null
2500
2500
  };
2501
2501
  }
2502
- case "home-logo": {
2503
- const lines = [];
2504
- const rows = [];
2505
- message.lines.forEach((line, i) => {
2506
- lines.push(line);
2507
- rows.push([{
2508
- text: line,
2509
- style: { color: message.colors[i] }
2510
- }]);
2511
- });
2512
- for (const line of message.info) lines.push(truncate(line, Math.max(8, inner)));
2513
- return {
2514
- lines,
2515
- rows: rows.length === 0 ? null : rows,
2516
- bgs: null
2517
- };
2518
- }
2519
2502
  case "custom": {
2520
2503
  const view = messageViewOf(message.view);
2521
2504
  if (view === void 0) return {
@@ -2659,13 +2642,6 @@ function planFor(message, body) {
2659
2642
  blankRow()
2660
2643
  ];
2661
2644
  }
2662
- case "home-logo": return [...bodyRows(body.lines.length, {
2663
- colStart: 4,
2664
- bg: false,
2665
- muted: false,
2666
- selectable: true,
2667
- role: "assistant"
2668
- }), blankRow()];
2669
2645
  case "custom": return [
2670
2646
  {
2671
2647
  type: "header",
@@ -2734,12 +2710,6 @@ function fingerprintOf(message) {
2734
2710
  running: message.running,
2735
2711
  streaming: message.streaming
2736
2712
  };
2737
- case "home-logo": return {
2738
- kind: "home-logo",
2739
- lines: message.lines,
2740
- colors: message.colors,
2741
- info: message.info
2742
- };
2743
2713
  }
2744
2714
  }
2745
2715
  function fingerprintMatches(entry, message) {
@@ -2749,7 +2719,6 @@ function fingerprintMatches(entry, message) {
2749
2719
  case "tool-card": return message.kind === "tool-card" && entry.tool === message.tool && entry.label === message.label && entry.argsBody === message.argsBody && entry.resultBody === message.resultBody && entry.diff === message.diff && entry.read === message.read && entry.nested === message.nested && entry.error === message.error && entry.failed === message.failed && entry.exitCode === message.exitCode && entry.signal === message.signal && entry.bodyCol === message.bodyCol && entry.running === message.running && entry.streaming === message.streaming;
2750
2720
  case "compaction": return message.kind === "compaction" && entry.summary === message.summary && entry.running === message.running && entry.error === message.error;
2751
2721
  case "custom": return message.kind === "custom" && entry.view === message.view && entry.data === message.data && entry.running === message.running && entry.streaming === message.streaming;
2752
- case "home-logo": return message.kind === "home-logo" && entry.lines === message.lines && entry.colors === message.colors && entry.info === message.info;
2753
2722
  }
2754
2723
  }
2755
2724
  function renderFor(message, width) {
@@ -6146,59 +6115,6 @@ function backgroundFor(role) {
6146
6115
  return role === "user" ? COLORS.userBubbleBackground : COLORS.aiBubbleBackground;
6147
6116
  }
6148
6117
  //#endregion
6149
- //#region src/ui/message/message-list.tsx
6150
- function MessageList({ messages, height, width, scrollTop, onScroll, interactive = true, themeTick = 0 }) {
6151
- const index = rowIndexFor(messages, width);
6152
- const total = index.total;
6153
- const halfPage = Math.max(1, Math.ceil(height / 2));
6154
- useInput((input, key) => {
6155
- if (!interactive || isKeyConsumed()) return;
6156
- const maxScroll = Math.max(0, total - height);
6157
- const scroll = (delta) => {
6158
- onScroll(Math.max(0, Math.min(maxScroll, scrollTop + delta)));
6159
- };
6160
- if (key.pageUp) scroll(-(height - 2));
6161
- if (key.pageDown) scroll(height - 2);
6162
- if (key.ctrl && input === "u") scroll(-halfPage);
6163
- if (key.ctrl && input === "d") scroll(halfPage);
6164
- });
6165
- const rows = [];
6166
- const endRow = Math.min(scrollTop + height, total);
6167
- for (let row = scrollTop; row < endRow; row++) {
6168
- const info = index.rowAt(row);
6169
- if (info) rows.push(/* @__PURE__ */ jsx(MessageRow, {
6170
- info,
6171
- row,
6172
- themeTick
6173
- }, `${info.messageId}:${info.lineNo}`));
6174
- }
6175
- const scrollbar = scrollbarGeometry(total, height, scrollTop);
6176
- return /* @__PURE__ */ jsxs(Box, {
6177
- width,
6178
- height,
6179
- flexDirection: "column",
6180
- overflow: "hidden",
6181
- children: [/* @__PURE__ */ jsx(Region, {
6182
- y: -scrollTop,
6183
- children: rows
6184
- }), scrollbar !== null && /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx(Box, {
6185
- position: "absolute",
6186
- top: 0,
6187
- left: scrollbarColumn(width),
6188
- width: 1,
6189
- height,
6190
- backgroundColor: COLORS.scrollTrackBackground
6191
- }), /* @__PURE__ */ jsx(Box, {
6192
- position: "absolute",
6193
- top: scrollbar.top,
6194
- left: scrollbarColumn(width),
6195
- width: 1,
6196
- height: scrollbar.height,
6197
- backgroundColor: COLORS.scrollThumbBackground
6198
- })] })]
6199
- });
6200
- }
6201
- //#endregion
6202
6118
  //#region src/contract/upstream.ts
6203
6119
  const UPSTREAM_FLOOR_VERSION = "0.1.2-rc.1";
6204
6120
  const UPSTREAM_CEILING_VERSION = "0.2.0";
@@ -6405,34 +6321,41 @@ function homeLogoLineColors(lines, top, bottom) {
6405
6321
  const step = 1 / (count - 1);
6406
6322
  return lines.map((_, i) => mixHex(top, bottom, i * step));
6407
6323
  }
6408
- const HOME_LOGO_ID = "home-logo";
6324
+ const BLOCK_GLYPHS = /* @__PURE__ */ new Set([
6325
+ "█",
6326
+ "▀",
6327
+ "▄",
6328
+ " "
6329
+ ]);
6409
6330
  /**
6410
- * The home page presents its content in chat-page style: the logo artwork is
6411
- * the first bubble, left-aligned with a transparent background, followed by
6412
- * version banner lines inside the same bubble. `width` is the full list
6413
- * width and `height` the visible viewport; when either cannot hold the
6414
- * artwork, only the artwork lines are culled and the bubble stays.
6331
+ * Displace pure block-glyph artwork down by half a cell: every cell's
6332
+ * top/bottom half ink moves one pixel-row down and is re-encoded as
6333
+ * ▀/▄/█. The shape is pixel-identical, but the artwork's bottom edge lands
6334
+ * on a cell boundary, so vertically centered text beside it reads as
6335
+ * aligned with the bottom line. Artwork containing any non-block glyph is
6336
+ * returned unchanged — text glyphs cannot be split across cells.
6415
6337
  */
6416
- function useHomeLogoBubble(width, height) {
6417
- const logo = useHomeLogo();
6418
- return useMemo(() => homeLogoBubble(logo, width, height), [
6419
- logo,
6420
- width,
6421
- height
6422
- ]);
6423
- }
6424
- function homeLogoBubble(logo, width, height) {
6425
- const clean = logo?.lines.map((line) => line.trimEnd()) ?? [];
6426
- const metrics = homeLogoMetrics(clean);
6427
- const fits = clean.length > 0 && width - 8 >= metrics.columns && height >= metrics.rows;
6428
- return {
6429
- kind: "home-logo",
6430
- id: HOME_LOGO_ID,
6431
- lines: fits ? clean : [],
6432
- colors: fits ? homeLogoLineColors(clean, COLORS.homeLogoTop, COLORS.homeLogoBottom) : [],
6433
- info: [dshVersionLine(), `dshtui ${tuiVersion()}`]
6434
- };
6338
+ function shiftLogoDownHalfRow(lines) {
6339
+ const width = lines.reduce((max, line) => Math.max(max, line.length), 0);
6340
+ if (width === 0) return [...lines];
6341
+ for (const line of lines) for (const ch of line) if (!BLOCK_GLYPHS.has(ch)) return [...lines];
6342
+ const top = lines.map((line) => Array.from({ length: width }, (_, c) => line[c] === "█" || line[c] === "▀"));
6343
+ const bottom = lines.map((line) => Array.from({ length: width }, (_, c) => line[c] === "█" || line[c] === "▄"));
6344
+ const rows = [];
6345
+ for (let r = 0; r <= lines.length; r++) {
6346
+ let row = "";
6347
+ let ink = false;
6348
+ for (let c = 0; c < width; c++) {
6349
+ const t = r > 0 && bottom[r - 1][c];
6350
+ const b = r < lines.length && top[r][c];
6351
+ row += t && b ? "█" : t ? "" : b ? "▄" : " ";
6352
+ ink = ink || t || b;
6353
+ }
6354
+ if (ink) rows.push(row.trimEnd());
6355
+ }
6356
+ return rows;
6435
6357
  }
6358
+ /** The running harness version, `dsh <version>`. */
6436
6359
  function dshVersionLine() {
6437
6360
  return `dsh ${installedUpstreamLines()[0] ?? "unknown"}`;
6438
6361
  }
@@ -6448,6 +6371,92 @@ function tuiVersion() {
6448
6371
  return cachedTuiVersion;
6449
6372
  }
6450
6373
  //#endregion
6374
+ //#region src/ui/message/message-list.tsx
6375
+ /** Gap between the artwork's right edge and an appended version label. */
6376
+ const VERSION_GAP = 2;
6377
+ function MessageList({ messages, height, width, scrollTop, onScroll, interactive = true, themeTick = 0 }) {
6378
+ const index = rowIndexFor(messages, width);
6379
+ const total = index.total;
6380
+ const halfPage = Math.max(1, Math.ceil(height / 2));
6381
+ useInput((input, key) => {
6382
+ if (!interactive || isKeyConsumed()) return;
6383
+ const maxScroll = Math.max(0, total - height);
6384
+ const scroll = (delta) => {
6385
+ onScroll(Math.max(0, Math.min(maxScroll, scrollTop + delta)));
6386
+ };
6387
+ if (key.pageUp) scroll(-(height - 2));
6388
+ if (key.pageDown) scroll(height - 2);
6389
+ if (key.ctrl && input === "u") scroll(-halfPage);
6390
+ if (key.ctrl && input === "d") scroll(halfPage);
6391
+ });
6392
+ const logo = useHomeLogo();
6393
+ if (messages.length === 0) {
6394
+ const clean = shiftLogoDownHalfRow(logo?.lines.map((line) => line.trimEnd()) ?? []);
6395
+ const metrics = homeLogoMetrics(clean);
6396
+ const dshtuiVersionLine = `dshtui ${tuiVersion()}`;
6397
+ const versionWidth = Math.max(dshVersionLine().length, dshtuiVersionLine.length);
6398
+ const anchorWidth = clean.length >= 2 ? Math.max(textWidth(clean[clean.length - 2].trimEnd()), textWidth(clean[clean.length - 1].trimEnd())) : metrics.columns;
6399
+ const fits = logo !== void 0 && width >= 2 * (anchorWidth + VERSION_GAP + versionWidth) - metrics.columns && height >= metrics.rows;
6400
+ const colors = homeLogoLineColors(clean, COLORS.homeLogoTop, COLORS.homeLogoBottom);
6401
+ const left = Math.floor((width - metrics.columns) / 2);
6402
+ const rowText = (i) => {
6403
+ const artwork = clean[i].padEnd(i >= clean.length - 2 ? anchorWidth : metrics.columns);
6404
+ if (i === clean.length - 1) return `${artwork}${" ".repeat(VERSION_GAP)}${dshtuiVersionLine}`;
6405
+ if (i === clean.length - 2) return `${artwork}${" ".repeat(VERSION_GAP)}${dshVersionLine()}`;
6406
+ return artwork;
6407
+ };
6408
+ return /* @__PURE__ */ jsx(Box, {
6409
+ width,
6410
+ height,
6411
+ flexDirection: "column",
6412
+ justifyContent: "center",
6413
+ overflow: "hidden",
6414
+ children: fits && colors.map((color, i) => /* @__PURE__ */ jsx(Box, {
6415
+ marginLeft: left,
6416
+ children: /* @__PURE__ */ jsx(Text, {
6417
+ color,
6418
+ children: rowText(i)
6419
+ })
6420
+ }, i))
6421
+ });
6422
+ }
6423
+ const rows = [];
6424
+ const endRow = Math.min(scrollTop + height, total);
6425
+ for (let row = scrollTop; row < endRow; row++) {
6426
+ const info = index.rowAt(row);
6427
+ if (info) rows.push(/* @__PURE__ */ jsx(MessageRow, {
6428
+ info,
6429
+ row,
6430
+ themeTick
6431
+ }, `${info.messageId}:${info.lineNo}`));
6432
+ }
6433
+ const scrollbar = scrollbarGeometry(total, height, scrollTop);
6434
+ return /* @__PURE__ */ jsxs(Box, {
6435
+ width,
6436
+ height,
6437
+ flexDirection: "column",
6438
+ overflow: "hidden",
6439
+ children: [/* @__PURE__ */ jsx(Region, {
6440
+ y: -scrollTop,
6441
+ children: rows
6442
+ }), scrollbar !== null && /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx(Box, {
6443
+ position: "absolute",
6444
+ top: 0,
6445
+ left: scrollbarColumn(width),
6446
+ width: 1,
6447
+ height,
6448
+ backgroundColor: COLORS.scrollTrackBackground
6449
+ }), /* @__PURE__ */ jsx(Box, {
6450
+ position: "absolute",
6451
+ top: scrollbar.top,
6452
+ left: scrollbarColumn(width),
6453
+ width: 1,
6454
+ height: scrollbar.height,
6455
+ backgroundColor: COLORS.scrollThumbBackground
6456
+ })] })]
6457
+ });
6458
+ }
6459
+ //#endregion
6451
6460
  //#region src/chat/models.ts
6452
6461
  const API_PROTOCOLS = [
6453
6462
  "openai-completions",
@@ -7188,9 +7197,9 @@ function sessionTime(session) {
7188
7197
  * with the client-side visibility rules: live sessions plus persisted cold
7189
7198
  * sessions with a cwd, blank and subagent-origin rows hidden, newest-first,
7190
7199
  * without any workspace filtering (the picker labels ungrouped sessions).
7191
- * Cold rows read projection-cache hints or a size-gated small-artifact
7192
- * probe; a hint-less conversation title comes from the cache's cold ladder
7193
- * (one log read, then persisted), never a repeated full read.
7200
+ * Cold rows take their titles from the host's batched session-title fold
7201
+ * (`sessionQuery.readTitleSnapshots`), which reads each log once per
7202
+ * release of the query corpus and never loads full sessions into memory.
7194
7203
  */
7195
7204
  async function computeSessionList(ctx) {
7196
7205
  try {
@@ -7215,8 +7224,7 @@ async function computeSessionListInner(ctx) {
7215
7224
  const title = titleService?.get?.(session)?.title ?? firstUserText(session.snapshotEvents());
7216
7225
  summaries.push(toSummary(id, title, session.header.cwd, session.header.createdAt, lastPromptAt(session.snapshotEvents()), workspacePaths));
7217
7226
  }
7218
- const persistence = ctx.get("sessionPersistence");
7219
- await mergeColdSummaries(ctx, persistence, await listColdHeaders(ctx, persistence, attached), archived, workspacePaths, summaries);
7227
+ await mergeColdSummaries(ctx, await listColdHeaders(ctx, ctx.get("sessionPersistence"), attached), archived, workspacePaths, summaries);
7220
7228
  await attachModifiedTimes(ctx, summaries);
7221
7229
  return summaries.sort((a, b) => sessionTime(b) - sessionTime(a));
7222
7230
  }
@@ -7277,102 +7285,26 @@ function lastPromptAt(events) {
7277
7285
  for (const event of events) if (event.type === "user/message" && event.data.source.kind === "user") last = event.time;
7278
7286
  return last;
7279
7287
  }
7280
- const SESSION_META_CACHE_MAX = 512;
7281
- const sessionMetaCache = /* @__PURE__ */ new Map();
7282
- function cacheSessionMeta(id, entry) {
7283
- while (sessionMetaCache.size >= SESSION_META_CACHE_MAX) {
7284
- const oldest = sessionMetaCache.keys().next();
7285
- if (oldest.done) break;
7286
- sessionMetaCache.delete(oldest.value);
7287
- }
7288
- sessionMetaCache.set(id, entry);
7289
- }
7290
- const PROBE_BATCH_SIZE = 16;
7291
- /** Mirrors the host's cold blank-probe gate: only small artifacts are read. */
7292
- const COLD_PROBE_MAX_BYTES = 1024;
7293
- function listMetadataHint(values) {
7294
- const hint = values.sessionListMetadata;
7295
- if (typeof hint?.blank !== "boolean") return void 0;
7296
- return {
7297
- blank: hint.blank,
7298
- lastPromptAt: typeof hint.lastPromptAt === "number" ? hint.lastPromptAt : null
7299
- };
7300
- }
7301
- /**
7302
- * Cold-session metadata without unconditional full-log reads: projection
7303
- * hints first (zero I/O), then a direct read only when the artifact is small
7304
- * enough to be a blank-session log. A hint-less oversized artifact is
7305
- * conservatively visible; its title comes from the projection cache's cold
7306
- * ladder, which reads the log once and writes the row back durably.
7307
- */
7308
- async function resolveColdMeta(cache, persistence, header) {
7309
- let titleHint;
7310
- let metadata;
7311
- try {
7312
- const values = cache?.cachedSnapshot?.(header)?.values ?? {};
7313
- if (typeof values.title === "string" && values.title !== "") titleHint = values.title;
7314
- metadata = listMetadataHint(values);
7315
- } catch {}
7316
- if (metadata?.blank === false) return {
7317
- title: await coldTitle(cache, header, titleHint),
7318
- blank: false,
7319
- promptAt: metadata.lastPromptAt ?? 0
7320
- };
7321
- const probed = await probeSmallArtifact(persistence, header);
7322
- if (probed !== void 0) return {
7323
- title: probed.title ?? titleHint,
7324
- blank: probed.blank,
7325
- promptAt: probed.promptAt
7326
- };
7327
- return {
7328
- title: await coldTitle(cache, header, titleHint),
7329
- blank: false,
7330
- promptAt: metadata?.lastPromptAt ?? 0
7331
- };
7332
- }
7333
- async function coldTitle(cache, header, titleHint) {
7334
- if (titleHint !== void 0 || cache?.coldSnapshot === void 0) return titleHint;
7335
- try {
7336
- const title = (await cache.coldSnapshot(String(header.id)))?.values?.title;
7337
- return typeof title === "string" && title !== "" ? title : void 0;
7338
- } catch {
7339
- return;
7340
- }
7341
- }
7342
- async function probeSmallArtifact(persistence, header) {
7343
- if (persistence.locate === void 0 || persistence.readFrom === void 0) return void 0;
7344
- const location = persistence.locate(header);
7345
- if (location === void 0) return void 0;
7346
- try {
7347
- if ((await stat(location.path)).size > COLD_PROBE_MAX_BYTES) return void 0;
7348
- const { events } = await persistence.readFrom(String(header.id), 0);
7349
- return {
7350
- blank: isBlankSession(events),
7351
- title: titleFromEvents(events) ?? firstUserText(events),
7352
- promptAt: lastPromptAt(events)
7353
- };
7354
- } catch {
7355
- return;
7288
+ async function mergeColdSummaries(ctx, cold, archived, workspacePaths, summaries) {
7289
+ const query = ctx.get("sessionQuery");
7290
+ if (query?.readTitleSnapshots === void 0) return;
7291
+ const observed = await query.readTitleSnapshots(cold.map((header) => String(header.id))).catch(() => []);
7292
+ const titleById = /* @__PURE__ */ new Map();
7293
+ for (const entry of observed) {
7294
+ if (entry.status !== "fulfilled") continue;
7295
+ const snapshot = entry.value?.title;
7296
+ if (snapshot?.title === void 0 || snapshot.title === "") continue;
7297
+ titleById.set(String(entry.sessionId), {
7298
+ title: snapshot.title,
7299
+ promptAt: snapshot.updatedAt ?? 0
7300
+ });
7356
7301
  }
7357
- }
7358
- async function mergeColdSummaries(ctx, persistence, cold, archived, workspacePaths, summaries) {
7359
- if (persistence === void 0) return;
7360
- const cache = ctx.get("sessionProjectionCache");
7361
- const pending = [];
7362
7302
  for (const header of cold) {
7363
7303
  if (archived.has(String(header.id))) continue;
7364
- const cached = sessionMetaCache.get(String(header.id));
7365
- if (cached !== void 0 && cached.blank !== void 0) {
7366
- if (!cached.blank) summaries.push(toSummary(String(header.id), cached.title, header.cwd, header.createdAt, cached.promptAt, workspacePaths));
7367
- continue;
7368
- }
7369
- pending.push(header);
7304
+ const meta = titleById.get(String(header.id));
7305
+ if (meta === void 0) continue;
7306
+ summaries.push(toSummary(String(header.id), meta.title, header.cwd, header.createdAt, meta.promptAt, workspacePaths));
7370
7307
  }
7371
- for (let index = 0; index < pending.length; index += PROBE_BATCH_SIZE) await Promise.all(pending.slice(index, index + PROBE_BATCH_SIZE).map(async (header) => {
7372
- const meta = await resolveColdMeta(cache, persistence, header);
7373
- cacheSessionMeta(String(header.id), meta);
7374
- if (!meta.blank) summaries.push(toSummary(String(header.id), meta.title, header.cwd, header.createdAt, meta.promptAt, workspacePaths));
7375
- }));
7376
7308
  }
7377
7309
  function fallbackName(id, directory) {
7378
7310
  if (directory !== void 0 && directory !== "") {
@@ -7381,14 +7313,6 @@ function fallbackName(id, directory) {
7381
7313
  }
7382
7314
  return id.match(/[^/]+$/)?.[0] ?? id;
7383
7315
  }
7384
- function titleFromEvents(events) {
7385
- let title;
7386
- for (const event of events) if (event.type === "session/title") {
7387
- const data = event.data;
7388
- if (data?.title !== void 0 && data.title !== "") title = data.title;
7389
- }
7390
- return title;
7391
- }
7392
7316
  function firstUserText(events) {
7393
7317
  for (const event of events) {
7394
7318
  if (event.type !== "user/message") continue;
@@ -9070,9 +8994,7 @@ function App({ bridge, screen, themeTick = 0 }) {
9070
8994
  const permissionChrome = permissionModeInfo(permissionMode);
9071
8995
  const inputHeight = panel === null ? layout.barHeight : panelHeight + 1;
9072
8996
  const messageHeight = Math.max(1, rows - inputHeight - 1);
9073
- const logoBubble = useHomeLogoBubble(columns, messageHeight);
9074
- const displayMessages = messages.length === 0 ? [logoBubble] : messages;
9075
- const total = rowIndexFor(displayMessages, columns).total;
8997
+ const total = rowIndexFor(messages, columns).total;
9076
8998
  const { scrollTop, applyScroll, getScroll } = useScroll(total, messageHeight);
9077
8999
  const startNewSession = () => {
9078
9000
  if (!bridge) return;
@@ -9166,7 +9088,7 @@ function App({ bridge, screen, themeTick = 0 }) {
9166
9088
  };
9167
9089
  const selectionRef = useRef(null);
9168
9090
  const { selection, messageAreaSelection, chromeSelection, setSelection, clearSelection } = useMouseSelection({
9169
- messages: displayMessages,
9091
+ messages,
9170
9092
  columns,
9171
9093
  rows,
9172
9094
  scrollTop,
@@ -9211,7 +9133,7 @@ function App({ bridge, screen, themeTick = 0 }) {
9211
9133
  if (key.ctrl && input === "c") {
9212
9134
  if (selection) {
9213
9135
  const text = copySelection(selection, {
9214
- messageText: (sel) => selectionText(displayMessages, columns, sel),
9136
+ messageText: (sel) => selectionText(messages, columns, sel),
9215
9137
  chromeText: (sel) => chromeSelectionText(sel)
9216
9138
  });
9217
9139
  if (text) writeClipboardText(text);
@@ -9236,7 +9158,7 @@ function App({ bridge, screen, themeTick = 0 }) {
9236
9158
  children: [
9237
9159
  /* @__PURE__ */ jsx(KeymapGate, {}),
9238
9160
  /* @__PURE__ */ jsx(MessageList, {
9239
- messages: displayMessages,
9161
+ messages,
9240
9162
  height: messageHeight,
9241
9163
  width: columns,
9242
9164
  scrollTop,
@@ -10973,13 +10895,6 @@ function warmRenderPipeline() {
10973
10895
  renderMarkdown(MARKDOWN_FIXTURE, 96);
10974
10896
  renderMarkdown(MARKDOWN_FIXTURE, 72);
10975
10897
  const index = buildRowIndex([
10976
- {
10977
- kind: "home-logo",
10978
- id: "warm-logo",
10979
- lines: ["██ ████"],
10980
- colors: ["#7aa2f7"],
10981
- info: ["dsh 0.0.0", "dshtui 0.0.0"]
10982
- },
10983
10898
  {
10984
10899
  kind: "bubble",
10985
10900
  id: "warm-user",
@@ -505,8 +505,8 @@ function buildPalette(ladder, hues, code, mode) {
505
505
  thinkCodeType: thinkCode.type,
506
506
  thinkCodeVariable: thinkCode.variable,
507
507
  thinkCodeConstant: thinkCode.variable,
508
- homeLogoTop: mode === "dark" ? "#2181ff" : "#0381ff",
509
- homeLogoBottom: mode === "dark" ? "#3a9aa0" : "#97c4ff"
508
+ homeLogoTop: mode === "dark" ? "#e1e1e1" : "#7f7f7f",
509
+ homeLogoBottom: mode === "dark" ? "#6c6c6c" : "#292929"
510
510
  };
511
511
  }
512
512
  const darkPalette = buildPalette(DARK_LADDER, DARK_HUES, DARK_CODE, "dark");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xp266/dshtui",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
4
4
  "type": "module",
5
5
  "description": "ink-based terminal UI plugin for DeepSeek Harness",
6
6
  "engines": {
@@ -23,9 +23,9 @@ export function sessionTime(session: SessionSummary): number {
23
23
  * with the client-side visibility rules: live sessions plus persisted cold
24
24
  * sessions with a cwd, blank and subagent-origin rows hidden, newest-first,
25
25
  * without any workspace filtering (the picker labels ungrouped sessions).
26
- * Cold rows read projection-cache hints or a size-gated small-artifact
27
- * probe; a hint-less conversation title comes from the cache's cold ladder
28
- * (one log read, then persisted), never a repeated full read.
26
+ * Cold rows take their titles from the host's batched session-title fold
27
+ * (`sessionQuery.readTitleSnapshots`), which reads each log once per
28
+ * release of the query corpus and never loads full sessions into memory.
29
29
  */
30
30
  export async function computeSessionList(ctx: Context): Promise<SessionSummary[]> {
31
31
  try {
@@ -55,7 +55,7 @@ async function computeSessionListInner(ctx: Context): Promise<SessionSummary[]>
55
55
  }
56
56
  const persistence = ctx.get('sessionPersistence') as PersistenceLike | undefined
57
57
  const cold = await listColdHeaders(ctx, persistence, attached)
58
- await mergeColdSummaries(ctx, persistence, cold, archived, workspacePaths, summaries)
58
+ await mergeColdSummaries(ctx, cold, archived, workspacePaths, summaries)
59
59
  await attachModifiedTimes(ctx, summaries)
60
60
  return summaries.sort((a, b) => sessionTime(b) - sessionTime(a))
61
61
  }
@@ -67,13 +67,18 @@ interface ColdHeader {
67
67
  origin?: string
68
68
  }
69
69
 
70
- interface SessionQueryLike {
71
- listSessions?(signal?: AbortSignal): Promise<Array<{ header: ColdHeader; live: boolean; persisted: boolean }>>
70
+ interface PersistenceLike {
71
+ list(signal?: AbortSignal): Promise<ColdHeader[]>
72
+ locate?(header: { cwd?: string; id: string }): { path: string } | undefined
72
73
  }
73
74
 
74
- interface ProjectionCacheLike {
75
- cachedSnapshot?(meta: ColdHeader): { asOfSeq: number; values: Record<string, unknown> } | undefined
76
- coldSnapshot?(id: string, signal?: AbortSignal): Promise<{ asOfSeq: number; values: Record<string, unknown> } | undefined>
75
+ interface SessionQueryLike {
76
+ listSessions?(signal?: AbortSignal): Promise<Array<{ header: ColdHeader; live: boolean; persisted: boolean }>>
77
+ readTitleSnapshots?(sessionIds: readonly string[]): Promise<Array<{
78
+ sessionId: string
79
+ status: 'fulfilled' | 'rejected'
80
+ value?: { title?: { title?: string; updatedAt?: number } }
81
+ }>>
77
82
  }
78
83
 
79
84
  /**
@@ -159,134 +164,30 @@ function lastPromptAt(events: readonly SessionEvent[]): number {
159
164
  return last
160
165
  }
161
166
 
162
- interface PersistenceLike {
163
- list(signal?: AbortSignal): Promise<ColdHeader[]>
164
- locate?(header: { cwd?: string; id: string }): { path: string } | undefined
165
- readFrom?(id: string, offset: number, signal?: AbortSignal): Promise<{ events: SessionEvent[] }>
166
- }
167
-
168
- interface SessionMetaCacheEntry {
169
- title: string | undefined
170
- blank: boolean | undefined
171
- promptAt: number
172
- }
173
-
174
- const SESSION_META_CACHE_MAX = 512
175
- const sessionMetaCache = new Map<string, SessionMetaCacheEntry>()
176
-
177
- function cacheSessionMeta(id: string, entry: SessionMetaCacheEntry): void {
178
- while (sessionMetaCache.size >= SESSION_META_CACHE_MAX) {
179
- const oldest = sessionMetaCache.keys().next()
180
- if (oldest.done) break
181
- sessionMetaCache.delete(oldest.value)
182
- }
183
- sessionMetaCache.set(id, entry)
184
- }
185
-
186
- const PROBE_BATCH_SIZE = 16
187
-
188
- /** Mirrors the host's cold blank-probe gate: only small artifacts are read. */
189
- const COLD_PROBE_MAX_BYTES = 1024
190
-
191
- interface ColdMeta {
192
- title: string | undefined
193
- blank: boolean
194
- promptAt: number
195
- }
196
-
197
- function listMetadataHint(values: Record<string, unknown>): { blank: boolean; lastPromptAt: number | null } | undefined {
198
- const hint = values.sessionListMetadata as { blank?: unknown; lastPromptAt?: unknown } | undefined
199
- if (typeof hint?.blank !== 'boolean') return undefined
200
- return {
201
- blank: hint.blank,
202
- lastPromptAt: typeof hint.lastPromptAt === 'number' ? hint.lastPromptAt : null,
203
- }
204
- }
205
-
206
- /**
207
- * Cold-session metadata without unconditional full-log reads: projection
208
- * hints first (zero I/O), then a direct read only when the artifact is small
209
- * enough to be a blank-session log. A hint-less oversized artifact is
210
- * conservatively visible; its title comes from the projection cache's cold
211
- * ladder, which reads the log once and writes the row back durably.
212
- */
213
- async function resolveColdMeta(cache: ProjectionCacheLike | undefined, persistence: PersistenceLike, header: ColdHeader): Promise<ColdMeta> {
214
- let titleHint: string | undefined
215
- let metadata: { blank: boolean; lastPromptAt: number | null } | undefined
216
- try {
217
- const values = cache?.cachedSnapshot?.(header)?.values ?? {}
218
- if (typeof values.title === 'string' && values.title !== '') titleHint = values.title
219
- metadata = listMetadataHint(values)
220
- } catch {
221
- // A broken projection cache must not block the session picker; fall back to probing.
222
- }
223
- if (metadata?.blank === false) {
224
- return { title: await coldTitle(cache, header, titleHint), blank: false, promptAt: metadata.lastPromptAt ?? 0 }
225
- }
226
- const probed = await probeSmallArtifact(persistence, header)
227
- if (probed !== undefined) {
228
- return { title: probed.title ?? titleHint, blank: probed.blank, promptAt: probed.promptAt }
229
- }
230
- return { title: await coldTitle(cache, header, titleHint), blank: false, promptAt: metadata?.lastPromptAt ?? 0 }
231
- }
232
-
233
- async function coldTitle(cache: ProjectionCacheLike | undefined, header: ColdHeader, titleHint: string | undefined): Promise<string | undefined> {
234
- if (titleHint !== undefined || cache?.coldSnapshot === undefined) return titleHint
235
- try {
236
- const snapshot = await cache.coldSnapshot(String(header.id))
237
- const title = snapshot?.values?.title
238
- return typeof title === 'string' && title !== '' ? title : undefined
239
- } catch {
240
- // A failed cold-snapshot read leaves the session without a title.
241
- return undefined
242
- }
243
- }
244
-
245
- async function probeSmallArtifact(persistence: PersistenceLike, header: ColdHeader): Promise<ColdMeta | undefined> {
246
- if (persistence.locate === undefined || persistence.readFrom === undefined) return undefined
247
- const location = persistence.locate(header)
248
- if (location === undefined) return undefined
249
- try {
250
- const info = await stat(location.path)
251
- if (info.size > COLD_PROBE_MAX_BYTES) return undefined
252
- const { events } = await persistence.readFrom(String(header.id), 0)
253
- return { blank: isBlankSession(events), title: titleFromEvents(events) ?? firstUserText(events), promptAt: lastPromptAt(events) }
254
- } catch {
255
- // An unreadable artifact is treated as a blank session (skipped in the list).
256
- return undefined
257
- }
258
- }
259
-
260
167
  async function mergeColdSummaries(
261
168
  ctx: Context,
262
- persistence: PersistenceLike | undefined,
263
169
  cold: ColdHeader[],
264
170
  archived: ReadonlySet<string>,
265
171
  workspacePaths: ReadonlySet<string>,
266
172
  summaries: SessionSummary[],
267
173
  ): Promise<void> {
268
- if (persistence === undefined) return
269
- const cache = ctx.get('sessionProjectionCache') as ProjectionCacheLike | undefined
270
- const pending: ColdHeader[] = []
174
+ const query = ctx.get('sessionQuery') as SessionQueryLike | undefined
175
+ if (query?.readTitleSnapshots === undefined) return
176
+ const observed = await query.readTitleSnapshots(cold.map(header => String(header.id))).catch(() => [])
177
+ const titleById = new Map<string, { title: string; promptAt: number }>()
178
+ for (const entry of observed) {
179
+ if (entry.status !== 'fulfilled') continue
180
+ const snapshot = entry.value?.title
181
+ if (snapshot?.title === undefined || snapshot.title === '') continue
182
+ titleById.set(String(entry.sessionId), { title: snapshot.title, promptAt: snapshot.updatedAt ?? 0 })
183
+ }
271
184
  for (const header of cold) {
272
185
  if (archived.has(String(header.id))) continue
273
- const cached = sessionMetaCache.get(String(header.id))
274
- if (cached !== undefined && cached.blank !== undefined) {
275
- if (!cached.blank) {
276
- summaries.push(toSummary(String(header.id), cached.title, header.cwd, header.createdAt, cached.promptAt, workspacePaths))
277
- }
278
- continue
279
- }
280
- pending.push(header)
281
- }
282
- for (let index = 0; index < pending.length; index += PROBE_BATCH_SIZE) {
283
- await Promise.all(pending.slice(index, index + PROBE_BATCH_SIZE).map(async header => {
284
- const meta = await resolveColdMeta(cache, persistence, header)
285
- cacheSessionMeta(String(header.id), meta)
286
- if (!meta.blank) {
287
- summaries.push(toSummary(String(header.id), meta.title, header.cwd, header.createdAt, meta.promptAt, workspacePaths))
288
- }
289
- }))
186
+ // No title snapshot means the session never ran a turn: the host's
187
+ // deterministic fallback titles every session with a user message.
188
+ const meta = titleById.get(String(header.id))
189
+ if (meta === undefined) continue
190
+ summaries.push(toSummary(String(header.id), meta.title, header.cwd, header.createdAt, meta.promptAt, workspacePaths))
290
191
  }
291
192
  }
292
193
 
@@ -299,17 +200,6 @@ function fallbackName(id: string, directory?: string): string {
299
200
  return match?.[0] ?? id
300
201
  }
301
202
 
302
- function titleFromEvents(events: readonly SessionEvent[]): string | undefined {
303
- let title: string | undefined
304
- for (const event of events) {
305
- if ((event as { type?: string }).type === 'session/title') {
306
- const data = (event as { data?: { title?: string } }).data
307
- if (data?.title !== undefined && data.title !== '') title = data.title
308
- }
309
- }
310
- return title
311
- }
312
-
313
203
  function firstUserText(events: readonly SessionEvent[]): string | undefined {
314
204
  for (const event of events) {
315
205
  if (event.type !== 'user/message') continue
@@ -304,7 +304,7 @@ export interface InputStatusContribution {
304
304
  export interface HomeLogoContribution {
305
305
  id: string
306
306
  order?: number
307
- /** Logo artwork rendered as the first chat-styled bubble on the home page, one string per terminal row. */
307
+ /** Logo artwork centered on the empty home page, one string per terminal row. */
308
308
  lines: readonly string[]
309
309
  }
310
310
 
@@ -73,17 +73,6 @@ export interface CompactionMessage {
73
73
  streaming?: boolean
74
74
  }
75
75
 
76
- export interface HomeLogoMessage {
77
- kind: 'home-logo'
78
- id: string
79
- /** Artwork lines after culling; empty when the terminal cannot fit the logo. */
80
- lines: readonly string[]
81
- /** One gradient color per artwork line, aligned with `lines`. */
82
- colors: readonly string[]
83
- /** Version banner lines rendered under the artwork inside the same bubble. */
84
- info: readonly string[]
85
- }
86
-
87
- export type MessageKind = 'bubble' | 'collapsible' | 'tool-card' | 'compaction' | 'custom' | 'home-logo'
76
+ export type MessageKind = 'bubble' | 'collapsible' | 'tool-card' | 'compaction' | 'custom'
88
77
 
89
- export type Message = BubbleMessage | CollapsibleMessage | ToolCardMessage | CompactionMessage | CustomMessage | HomeLogoMessage
78
+ export type Message = BubbleMessage | CollapsibleMessage | ToolCardMessage | CompactionMessage | CustomMessage
package/src/theme.ts CHANGED
@@ -246,8 +246,8 @@ function buildPalette(ladder: GrayLadder, hues: SemanticHues, code: CodeHues, mo
246
246
  thinkCodeVariable: thinkCode.variable,
247
247
  thinkCodeConstant: thinkCode.variable,
248
248
 
249
- homeLogoTop: mode === 'dark' ? '#2181ff' : '#0381ff',
250
- homeLogoBottom: mode === 'dark' ? '#3a9aa0' : '#97c4ff',
249
+ homeLogoTop: mode === 'dark' ? '#e1e1e1' : '#7f7f7f',
250
+ homeLogoBottom: mode === 'dark' ? '#6c6c6c' : '#292929',
251
251
  }
252
252
  }
253
253
 
package/src/ui/app.tsx CHANGED
@@ -30,7 +30,6 @@ import { useComposer } from './input/use-composer.ts'
30
30
  import type { ComposerSubmission } from './input/composer-fields.ts'
31
31
  import { Region } from './region.tsx'
32
32
  import { MessageList } from './message/message-list.tsx'
33
- import { useHomeLogoBubble } from './home-logo.ts'
34
33
  import { SpinnerTickProvider } from './spinner-tick.tsx'
35
34
  import { CloseGuardContext } from './dialog/dialog.tsx'
36
35
  import type { DialogHandle } from './dialog/dialog.tsx'
@@ -185,12 +184,7 @@ export function App({ bridge, screen, themeTick = 0 }: AppProps) {
185
184
  const bottomHeight = panel === null ? layout.barHeight : panelHeight + 1
186
185
  const inputHeight = bottomHeight
187
186
  const messageHeight = Math.max(1, rows - inputHeight - MESSAGE_INPUT_GAP_ROWS)
188
- // The home page is a chat-styled surface for special messages; the logo
189
- // bubble is its content. Any real message switches to the chat list and
190
- // the logo disappears.
191
- const logoBubble = useHomeLogoBubble(columns, messageHeight)
192
- const displayMessages = messages.length === 0 ? [logoBubble] : messages
193
- const total = rowIndexFor(displayMessages, columns).total
187
+ const total = rowIndexFor(messages, columns).total
194
188
  const { scrollTop, applyScroll, getScroll } = useScroll(total, messageHeight)
195
189
  const startNewSession = () => {
196
190
  if (!bridge) return
@@ -284,7 +278,7 @@ export function App({ bridge, screen, themeTick = 0 }: AppProps) {
284
278
  }
285
279
  const selectionRef = useRef<LineSelection | null>(null)
286
280
  const { selection, messageAreaSelection, chromeSelection, setSelection, clearSelection } = useMouseSelection({
287
- messages: displayMessages,
281
+ messages,
288
282
  columns,
289
283
  rows,
290
284
  scrollTop,
@@ -329,7 +323,7 @@ export function App({ bridge, screen, themeTick = 0 }: AppProps) {
329
323
  if (key.ctrl && input === 'c') {
330
324
  if (selection) {
331
325
  const text = copySelection(selection, {
332
- messageText: sel => selectionText(displayMessages, columns, sel),
326
+ messageText: sel => selectionText(messages, columns, sel),
333
327
  chromeText: sel => chromeSelectionText(sel),
334
328
  })
335
329
  if (text) writeClipboardText(text)
@@ -349,7 +343,7 @@ export function App({ bridge, screen, themeTick = 0 }: AppProps) {
349
343
  <Box flexDirection="column" width={columns} height={rows}>
350
344
  <KeymapGate />
351
345
  <MessageList
352
- messages={displayMessages}
346
+ messages={messages}
353
347
  height={messageHeight}
354
348
  width={columns}
355
349
  scrollTop={scrollTop}
@@ -1,9 +1,7 @@
1
1
  import { readFileSync } from 'node:fs'
2
2
  import { fileURLToPath } from 'node:url'
3
- import { useMemo, useSyncExternalStore } from 'react'
3
+ import { useSyncExternalStore } from 'react'
4
4
  import { installedUpstreamLines } from '../contract/upstream.ts'
5
- import { BUBBLE_WIDTH_OFFSET } from '../core/metrics.ts'
6
- import type { HomeLogoMessage } from '../model/message.ts'
7
5
  import { keyedRegistry } from '../kernel/registry.ts'
8
6
  import { textWidth } from '../core/text.ts'
9
7
  import { COLORS } from '../theme.ts'
@@ -83,34 +81,43 @@ export function homeLogoLineColors(lines: readonly string[], top: string, bottom
83
81
  return lines.map((_, i) => mixHex(top, bottom, i * step))
84
82
  }
85
83
 
86
- const HOME_LOGO_ID = 'home-logo'
84
+ const BLOCK_GLYPHS = new Set(['', '▀', '▄', ' '])
87
85
 
88
86
  /**
89
- * The home page presents its content in chat-page style: the logo artwork is
90
- * the first bubble, left-aligned with a transparent background, followed by
91
- * version banner lines inside the same bubble. `width` is the full list
92
- * width and `height` the visible viewport; when either cannot hold the
93
- * artwork, only the artwork lines are culled and the bubble stays.
87
+ * Displace pure block-glyph artwork down by half a cell: every cell's
88
+ * top/bottom half ink moves one pixel-row down and is re-encoded as
89
+ * ▀/▄/█. The shape is pixel-identical, but the artwork's bottom edge lands
90
+ * on a cell boundary, so vertically centered text beside it reads as
91
+ * aligned with the bottom line. Artwork containing any non-block glyph is
92
+ * returned unchanged — text glyphs cannot be split across cells.
94
93
  */
95
- export function useHomeLogoBubble(width: number, height: number): HomeLogoMessage {
96
- const logo = useHomeLogo()
97
- return useMemo(() => homeLogoBubble(logo, width, height), [logo, width, height])
98
- }
99
-
100
- function homeLogoBubble(logo: HomeLogoContribution | undefined, width: number, height: number): HomeLogoMessage {
101
- const clean = logo?.lines.map(line => line.trimEnd()) ?? []
102
- const metrics = homeLogoMetrics(clean)
103
- const fits = clean.length > 0 && width - BUBBLE_WIDTH_OFFSET >= metrics.columns && height >= metrics.rows
104
- return {
105
- kind: 'home-logo',
106
- id: HOME_LOGO_ID,
107
- lines: fits ? clean : [],
108
- colors: fits ? homeLogoLineColors(clean, COLORS.homeLogoTop, COLORS.homeLogoBottom) : [],
109
- info: [dshVersionLine(), `dshtui ${tuiVersion()}`],
94
+ export function shiftLogoDownHalfRow(lines: readonly string[]): string[] {
95
+ const width = lines.reduce((max, line) => Math.max(max, line.length), 0)
96
+ if (width === 0) return [...lines]
97
+ for (const line of lines) {
98
+ for (const ch of line) {
99
+ if (!BLOCK_GLYPHS.has(ch)) return [...lines]
100
+ }
101
+ }
102
+ const top: boolean[][] = lines.map(line => Array.from({ length: width }, (_, c) => line[c] === '█' || line[c] === '▀'))
103
+ const bottom: boolean[][] = lines.map(line => Array.from({ length: width }, (_, c) => line[c] === '█' || line[c] === '▄'))
104
+ const rows: string[] = []
105
+ for (let r = 0; r <= lines.length; r++) {
106
+ let row = ''
107
+ let ink = false
108
+ for (let c = 0; c < width; c++) {
109
+ const t = r > 0 && bottom[r - 1]![c]!
110
+ const b = r < lines.length && top[r]![c]!
111
+ row += t && b ? '█' : t ? '▀' : b ? '▄' : ' '
112
+ ink = ink || t || b
113
+ }
114
+ if (ink) rows.push(row.trimEnd())
110
115
  }
116
+ return rows
111
117
  }
112
118
 
113
- function dshVersionLine(): string {
119
+ /** The running harness version, `dsh <version>`. */
120
+ export function dshVersionLine(): string {
114
121
  // dsh-agent itself does not export ./package.json, so the version comes
115
122
  // from the lockstepped harness line any sibling package exposes.
116
123
  return `dsh ${installedUpstreamLines()[0] ?? 'unknown'}`
@@ -118,14 +125,14 @@ function dshVersionLine(): string {
118
125
 
119
126
  let cachedTuiVersion: string | undefined
120
127
 
121
- function tuiVersion(): string {
128
+ export function tuiVersion(): string {
122
129
  if (cachedTuiVersion !== undefined) return cachedTuiVersion
123
130
  try {
124
131
  const path = fileURLToPath(import.meta.resolve('@xp266/dshtui/package.json'))
125
132
  const manifest = JSON.parse(readFileSync(path, 'utf8')) as { version?: string }
126
133
  cachedTuiVersion = manifest.version ?? 'unknown'
127
134
  } catch {
128
- // The manifest may be unreadable in unusual install layouts; the bubble still renders.
135
+ // The manifest may be unreadable in unusual install layouts; the label still renders.
129
136
  cachedTuiVersion = 'unknown'
130
137
  }
131
138
  return cachedTuiVersion
@@ -248,16 +248,6 @@ function renderBody(message: Message, width: number): BodyRendered {
248
248
  }
249
249
  return { lines, rows, bgs: null }
250
250
  }
251
- case 'home-logo': {
252
- const lines: string[] = []
253
- const rows: Segment[][] = []
254
- message.lines.forEach((line, i) => {
255
- lines.push(line)
256
- rows.push([{ text: line, style: { color: message.colors[i] } }])
257
- })
258
- for (const line of message.info) lines.push(truncate(line, Math.max(8, inner)))
259
- return { lines, rows: rows.length === 0 ? null : rows, bgs: null }
260
- }
261
251
  case 'custom': {
262
252
  const view = messageViewOf(message.view)
263
253
  if (view === undefined) {
@@ -369,11 +359,6 @@ function planFor(message: Message, body: BodyRendered): PlanRow[] {
369
359
  blankRow(),
370
360
  ]
371
361
  }
372
- case 'home-logo':
373
- return [
374
- ...bodyRows(body.lines.length, { colStart: 4, bg: false, muted: false, selectable: true, role: 'assistant' }),
375
- blankRow(),
376
- ]
377
362
  case 'custom': {
378
363
  const header: PlanRow = {
379
364
  type: 'header',
@@ -403,7 +388,6 @@ type RenderFingerprint =
403
388
  | { kind: 'tool-card'; tool: string; label: string; argsBody: string; resultBody: string | undefined; diff: ToolCardMessage['diff']; read: ToolCardMessage['read']; nested: ToolCardMessage['nested']; error: string | undefined; failed: boolean | undefined; exitCode: number | undefined; signal: string | undefined; bodyCol: number | undefined; running: boolean; streaming: boolean | undefined }
404
389
  | { kind: 'compaction'; summary: string; running: boolean; error: string | undefined }
405
390
  | { kind: 'custom'; view: string; data: unknown; running: boolean | undefined; streaming: boolean | undefined }
406
- | { kind: 'home-logo'; lines: readonly string[]; colors: readonly string[]; info: readonly string[] }
407
391
 
408
392
  interface RenderMemoEntry {
409
393
  epoch: number
@@ -467,13 +451,6 @@ function fingerprintOf(message: Message): RenderFingerprint {
467
451
  running: message.running,
468
452
  streaming: message.streaming,
469
453
  }
470
- case 'home-logo':
471
- return {
472
- kind: 'home-logo',
473
- lines: message.lines,
474
- colors: message.colors,
475
- info: message.info,
476
- }
477
454
  }
478
455
  }
479
456
 
@@ -520,11 +497,6 @@ function fingerprintMatches(entry: RenderFingerprint, message: Message): boolean
520
497
  && entry.data === message.data
521
498
  && entry.running === message.running
522
499
  && entry.streaming === message.streaming
523
- case 'home-logo':
524
- return message.kind === 'home-logo'
525
- && entry.lines === message.lines
526
- && entry.colors === message.colors
527
- && entry.info === message.info
528
500
  }
529
501
  }
530
502
 
@@ -1,11 +1,13 @@
1
- import { Box, useInput } from 'ink'
1
+ import { Box, Text, useInput } from 'ink'
2
2
  import { isKeyConsumed } from '../key-arbiter.ts'
3
3
  import type { Message } from '../../model/message.ts'
4
4
  import { rowIndexFor, scrollbarGeometry } from './layout.ts'
5
5
  import { MessageRow } from './message-row.tsx'
6
+ import { textWidth } from '../../core/text.ts'
6
7
  import { COLORS } from '../../theme.ts'
7
8
  import { scrollbarColumn } from '../layout-service.ts'
8
9
  import { Region } from '../region.tsx'
10
+ import { dshVersionLine, homeLogoLineColors, homeLogoMetrics, shiftLogoDownHalfRow, tuiVersion, useHomeLogo } from '../home-logo.ts'
9
11
 
10
12
  interface MessageListProps {
11
13
  messages: Message[]
@@ -17,6 +19,9 @@ interface MessageListProps {
17
19
  themeTick?: number
18
20
  }
19
21
 
22
+ /** Gap between the artwork's right edge and an appended version label. */
23
+ const VERSION_GAP = 2
24
+
20
25
  export function MessageList({ messages, height, width, scrollTop, onScroll, interactive = true, themeTick = 0 }: MessageListProps) {
21
26
  const index = rowIndexFor(messages, width)
22
27
  const total = index.total
@@ -32,6 +37,41 @@ export function MessageList({ messages, height, width, scrollTop, onScroll, inte
32
37
  if (key.ctrl && input === 'u') scroll(-halfPage)
33
38
  if (key.ctrl && input === 'd') scroll(halfPage)
34
39
  })
40
+ const logo = useHomeLogo()
41
+ if (messages.length === 0) {
42
+ // Shift the block artwork down half a cell so its bottom edge meets the
43
+ // vertically centered version text.
44
+ const clean = shiftLogoDownHalfRow(logo?.lines.map(line => line.trimEnd()) ?? [])
45
+ const metrics = homeLogoMetrics(clean)
46
+ const dshtuiVersionLine = `dshtui ${tuiVersion()}`
47
+ const versionWidth = Math.max(dshVersionLine().length, dshtuiVersionLine.length)
48
+ // The labels hug the bottom word's ink (narrower than the widest row);
49
+ // the anchor is the widest of the two label rows so the labels line up
50
+ // even when the shifted artwork leaves them different lengths.
51
+ const anchorWidth = clean.length >= 2
52
+ ? Math.max(textWidth(clean[clean.length - 2]!.trimEnd()), textWidth(clean[clean.length - 1]!.trimEnd()))
53
+ : metrics.columns
54
+ // The version labels hang right of the centered artwork block, so the
55
+ // width must hold the label at its overhanging position.
56
+ const fits = logo !== undefined && width >= 2 * (anchorWidth + VERSION_GAP + versionWidth) - metrics.columns && height >= metrics.rows
57
+ const colors = homeLogoLineColors(clean, COLORS.homeLogoTop, COLORS.homeLogoBottom)
58
+ const left = Math.floor((width - metrics.columns) / 2)
59
+ const rowText = (i: number): string => {
60
+ const artwork = clean[i]!.padEnd(i >= clean.length - 2 ? anchorWidth : metrics.columns)
61
+ if (i === clean.length - 1) return `${artwork}${' '.repeat(VERSION_GAP)}${dshtuiVersionLine}`
62
+ if (i === clean.length - 2) return `${artwork}${' '.repeat(VERSION_GAP)}${dshVersionLine()}`
63
+ return artwork
64
+ }
65
+ return (
66
+ <Box width={width} height={height} flexDirection="column" justifyContent="center" overflow="hidden">
67
+ {fits && colors.map((color, i) => (
68
+ <Box key={i} marginLeft={left}>
69
+ <Text color={color}>{rowText(i)}</Text>
70
+ </Box>
71
+ ))}
72
+ </Box>
73
+ )
74
+ }
35
75
  const rows = []
36
76
  const endRow = Math.min(scrollTop + height, total)
37
77
  for (let row = scrollTop; row < endRow; row++) {
@@ -28,7 +28,6 @@ export function warmRenderPipeline(): void {
28
28
  renderMarkdown(MARKDOWN_FIXTURE, 96)
29
29
  renderMarkdown(MARKDOWN_FIXTURE, 72)
30
30
  const messages: Message[] = [
31
- { kind: 'home-logo', id: 'warm-logo', lines: ['██ ████'], colors: ['#7aa2f7'], info: ['dsh 0.0.0', 'dshtui 0.0.0'] },
32
31
  { kind: 'bubble', id: 'warm-user', role: 'user', content: 'warm up the render pipeline' },
33
32
  { kind: 'bubble', id: 'warm-ai', role: 'assistant', content: MARKDOWN_FIXTURE },
34
33
  { kind: 'collapsible', id: 'warm-think', label: 'Thinking', body: 'thinking fixture body', running: false, collapsed: false, thinking: true },