@itookit/dsht 0.6.3 → 0.6.7

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/README.i18n.yaml CHANGED
@@ -1,3 +1,3 @@
1
1
  # Git blob hashes of the reviewed bilingual pair.
2
- README.md: 02d559e19c88336d6eb1f1e1ff80999bbfaa7274
3
- README.zh.md: b962a44f8a85716f4bd35f8afd0af7d140ffe0ca
2
+ README.md: 1f5cde21326a6a1056ca90cba9e21e672ee332bb
3
+ README.zh.md: 977cf5defbbe36cf37ca0d999b915a4aa81bfbfd
package/README.md CHANGED
@@ -1,6 +1,14 @@
1
1
  # DeepSeek Harness Terminal
2
2
 
3
3
  English | [中文](README.zh.md)
4
+ Run at first time(run `npx @deepseek-ai/dsh web`, then copy the output to DSH_URL):
5
+ ```bash
6
+ export DSH_URL='http://127.0.0.1:3080/?token=<token>' && npx @itookit/dsht
7
+ ```
8
+ Then every time only run (make sure `npx @deepseek-ai/dsh web` has run):
9
+ ```bash
10
+ npx @itookit/dsht
11
+ ```
4
12
 
5
13
  ![DeepSeek Harness Terminal (dsht)](dsht-m.png)
6
14
 
@@ -166,7 +174,9 @@ npm start
166
174
 
167
175
  Both paths read the same `DSH_URL` and `DSH_TOKEN` variables.
168
176
 
169
- Select a workspace with ↑/↓ and Enter, then select a session or **New session**. **All sessions** also exposes sessions outside registered workspaces. **Add workspace (this directory)** registers the directory `dsht` itself runs in, and appears only while the host does not already have it; **Add workspace (host directory)** takes an existing absolute directory on the host, which may differ from your local filesystem, and Esc leaves that prompt for the picker again. Starting inside a registered workspace directory selects that workspace instead of showing the picker, with `←` in the session list switching to another; a session id passed on the command line still opens directly. Creating a session requires a selected workspace.
177
+ Select a workspace with ↑/↓ and Enter, then select a session or **New session**. **All sessions** also exposes sessions outside registered workspaces. **Add workspace (this directory)** registers the directory `dsht` itself runs in: while the host does not already have it, it leads the list and is therefore the default selection; **Add workspace (host directory)** takes an existing absolute directory on the host, which may differ from your local filesystem, and Esc leaves that prompt for the picker again. Starting inside a registered workspace directory selects that workspace instead of showing the picker, with `←` in the session list switching to another; a session id passed on the command line still opens directly. Creating a session requires a selected workspace.
178
+
179
+ While the host is unreachable, the startup pickers show what is wrong and how to fix it instead of an empty list. A first attempt still in flight is named as connecting; an unreachable host prints the `npx @deepseek-ai/dsh web` command that starts it together with the first-run `DSH_URL` line; a refused credential asks for that URL or `DSH_TOKEN`. The raw transport failure stays underneath as a dim diagnostic, and the client keeps retrying on its own.
170
180
 
171
181
  On first login, authentication exchanges the token at `GET /` and saves the cookie per HTTP origin. Later starts, including list commands, reuse that cookie without a token. The store uses `$XDG_STATE_HOME/dsht/auth`, or `~/.local/state/dsht/auth` when unset; `--auth-dir` or `DSHT_AUTH_DIR` overrides it. POSIX directories use 0700 and cookie files use 0600; Windows uses the account directory's inherited access controls. Launch tokens are never saved.
172
182
 
package/README.zh.md CHANGED
@@ -2,6 +2,14 @@
2
2
 
3
3
  [English](README.md) | 中文
4
4
 
5
+ 第一次运行时,需要使用 DSH_URL 传入 `npx @deepseek-ai/dsh web` 启动时打印的 url:
6
+ ```bash
7
+ export DSH_URL='http://127.0.0.1:3080/?token=<token>' && npx @itookit/dsht
8
+ ```
9
+ 以后运行只需要执行(当然必须确认`npx @deepseek-ai/dsh web` 已经启动):
10
+ ```bash
11
+ npx @itookit/dsht
12
+ ```
5
13
  ![DeepSeek Harness Terminal(dsht)](dsht-m.png)
6
14
 
7
15
  > **dsht — 只要有终端,就能随时控制 DeepSeek Harness。**
@@ -166,7 +174,9 @@ npm start
166
174
 
167
175
  两种方式读取相同的 `DSH_URL` 和 `DSH_TOKEN` 变量。
168
176
 
169
- 使用 ↑/↓ 和 Enter 选择工作区,然后选择已有会话或 **New session**。**All sessions** 同时显示未归属注册工作区的会话。**Add workspace (this directory)** 直接注册 `dsht` 自身所在的目录,只在服务端尚未注册它时出现;**Add workspace (host directory)** 接收服务端已有目录的绝对路径,该路径可能与本机文件系统不同,按 Esc 可以退回选择器。在已注册的工作区目录里启动时,直接选中该工作区而不再显示选择器(会话列表里的 `←` 仍可切到别的工作区);命令行给出会话 ID 时依旧直接打开。新建会话前必须选择工作区。
177
+ 使用 ↑/↓ 和 Enter 选择工作区,然后选择已有会话或 **New session**。**All sessions** 同时显示未归属注册工作区的会话。**Add workspace (this directory)** 直接注册 `dsht` 自身所在的目录:服务端尚未注册它时,它排在列表最前,因此也是默认选中项;**Add workspace (host directory)** 接收服务端已有目录的绝对路径,该路径可能与本机文件系统不同,按 Esc 可以退回选择器。在已注册的工作区目录里启动时,直接选中该工作区而不再显示选择器(会话列表里的 `←` 仍可切到别的工作区);命令行给出会话 ID 时依旧直接打开。新建会话前必须选择工作区。
178
+
179
+ 宿主不可达时,启动选择器显示的是原因与解决办法,而不是一个空列表:尚在进行的首次尝试写成"连接中";连不上宿主时给出启动它的 `npx @deepseek-ai/dsh web` 命令与首次运行所需的 `DSH_URL` 行;凭据被拒时要求该 URL 或 `DSH_TOKEN`。原始传输错误以暗色诊断信息留在下方,客户端自行持续重连。
170
180
 
171
181
  首次登录通过 `GET /` 兑换 token,并按 HTTP origin 保存 cookie。后续启动和列表命令自动复用 cookie,无需再次提供 token。默认目录为 `$XDG_STATE_HOME/dsht/auth`,未设置时使用 `~/.local/state/dsht/auth`;可通过 `--auth-dir` 或 `DSHT_AUTH_DIR` 覆盖。POSIX 下目录权限为 0700、cookie 文件为 0600;Windows 使用账户目录继承的访问控制。启动 token 永不保存。
172
182
 
package/dist/ui/app.js CHANGED
@@ -21,7 +21,8 @@ import { plainRows } from "./chat/shell-view.js";
21
21
  import { useHistoryView } from "./chat/use-history-view.js";
22
22
  import { Frozen } from "./frozen.js";
23
23
  import { CopyMode } from "./copy-mode.js";
24
- import { HelpPanel, HistoryDialog, ModelDialog, PickerScreen, PromptsDialog, QueueDialog, QueuedPreview, RemovalDialog, SearchResultsDialog, ThoughtsDialog } from "./dialogs/index.js";
24
+ import { HelpPanel, HistoryDialog, ModelDialog, OfflinePanel, PickerScreen, PromptsDialog, QueueDialog, QueuedPreview, RemovalDialog, SearchResultsDialog, ThoughtsDialog } from "./dialogs/index.js";
25
+ import { offlineGuidance } from "./offline.js";
25
26
  import { LoopDialog, LoopMenu } from "./dialogs/loop.js";
26
27
  import { usePanels } from "./dialogs/use-panels.js";
27
28
  import { PeekPanel } from "./dialogs/peek.js";
@@ -830,6 +831,13 @@ export function App({ controller, panelLifetimeMs = PANEL_LIFETIME_MS, theme = m
830
831
  // Needs-you is the one state that asks for action, so it outranks working; ready stays quiet.
831
832
  const stateColor = { needs: theme.status.critical, running: theme.status.working, idle: theme.colors.muted };
832
833
  const choices = state.screen === 'workspaces' ? [
834
+ // The directory this client runs in is the one case where no path has to be typed, and offering
835
+ // it only while the host has not registered it keeps the row from repeating itself. It leads the
836
+ // list so it is also the default selection: starting outside a registered workspace is exactly
837
+ // the situation the row exists for.
838
+ ...(state.workspaces.some(workspace => string(workspace.path) === controller.localDirectory) ? []
839
+ : [{ key: '@here', label: `+ Add workspace (this directory) ${controller.localDirectory}`,
840
+ action: () => operate(() => controller.actions.createWorkspace(controller.localDirectory)) }]),
833
841
  ...state.workspaces.map(workspace => {
834
842
  const counts = workspaceCounts(sessionsOf(workspace), new Set(pendingCounts.keys()));
835
843
  const name = string(workspace.title);
@@ -844,11 +852,6 @@ export function App({ controller, panelLifetimeMs = PANEL_LIFETIME_MS, theme = m
844
852
  action: () => controller.actions.pickWorkspace(string(workspace.workspaceId)) };
845
853
  }),
846
854
  { key: '@all', label: 'All sessions', action: () => operate(() => controller.actions.switchSession('all')) },
847
- // The directory this client runs in is the one case where no path has to be typed, and offering
848
- // it only while the host has not registered it keeps the row from repeating itself.
849
- ...(state.workspaces.some(workspace => string(workspace.path) === controller.localDirectory) ? []
850
- : [{ key: '@here', label: `+ Add workspace (this directory) ${controller.localDirectory}`,
851
- action: () => operate(() => controller.actions.createWorkspace(controller.localDirectory)) }]),
852
855
  { key: '@new', label: '+ Add workspace (host directory)', action: () => controller.actions.enterPath() },
853
856
  ] : [
854
857
  ...(state.workspaceId && !state.showAllSessions ? [{ key: '@new', label: '+ New session', action: () => operate(() => controller.actions.createSession()) }] : []),
@@ -882,6 +885,12 @@ export function App({ controller, panelLifetimeMs = PANEL_LIFETIME_MS, theme = m
882
885
  const peekPlainRows = useMemo(() => peek?.lines === undefined ? []
883
886
  : peek.lines.flatMap(line => plainRows(line, width, 'shell')), [peek?.lines, width]);
884
887
  const statusNotice = !['Connected', 'Idle', 'Running…', 'Responding…'].includes(state.status);
888
+ // A startup picker has nothing to list while the host is out of reach, so it carries the guidance
889
+ // in place of an empty list; every other screen keeps the status and failure lines instead.
890
+ const pickerScreen = state.screen === 'workspaces' || state.screen === 'sessions';
891
+ const offline = pickerScreen && !state.online
892
+ ? offlineGuidance({ status: state.status, base: controller.base, lastFailure: state.lastFailure })
893
+ : undefined;
885
894
  async function openSearchSession(sessionId, query) {
886
895
  await historyOperation(async (signal) => {
887
896
  if (!await controller.actions.selectSession(sessionId))
@@ -1038,13 +1047,14 @@ export function App({ controller, panelLifetimeMs = PANEL_LIFETIME_MS, theme = m
1038
1047
  missing: ledger.missing(),
1039
1048
  };
1040
1049
  })();
1041
- return _jsx(ThemeContext.Provider, { value: theme, children: _jsx(CopyMode.Provider, { value: copyMode, children: _jsx(Frozen, { frozen: copyMode, identity: `${width}:${stdout.rows}`, children: _jsxs(Box, { flexDirection: "column", paddingX: 1, height: Math.max(1, (stdout.rows ?? 30) - 1), overflowY: "hidden", children: [copyMode && _jsx(Text, { color: theme.accent, children: "Copy mode \u00B7 drag to select \u00B7 Esc / Ctrl+S resumes" }), _jsx(Box, { ref: headerSection, flexShrink: 0, children: _jsx(ChatHeader, { title: headerTitle, mode: controller.queries.sessionMode, width: width, frozen: displayPaused, identity: `${width}:${state.sessionId}` }) }), _jsxs(Box, { flexDirection: "column", flexGrow: 1, flexShrink: 1, minHeight: 0, overflowY: "hidden", children: [_jsxs(Box, { ref: bodyPrefix, flexDirection: "column", flexShrink: 0, children: [foreground && _jsxs(Text, { dimColor: true, children: [foreground.label, " \u00B7 Esc / Ctrl+C cancel"] }), _jsx(Frozen, { frozen: statusPaused, identity: state.sessionId ?? "", children: statusNotice && _jsx(Text, { dimColor: true, wrap: "truncate-end", children: safeText(state.status) }) }), state.lastFailure && _jsx(Text, { color: theme.colors.error, children: state.lastFailure })] }), state.screen === 'chat' && (peek === undefined ? _jsx(ChatViewport, { rows: visible, showHistoryHint: showHistoryHint, dialogOpen: dialogOpen, historyWindow: !!historyWindow, frozen: displayPaused, identity: `${width}:${state.sessionId}:${position}:${pageSize}`, boxRef: conversationBox })
1050
+ return _jsx(ThemeContext.Provider, { value: theme, children: _jsx(CopyMode.Provider, { value: copyMode, children: _jsx(Frozen, { frozen: copyMode, identity: `${width}:${stdout.rows}`, children: _jsxs(Box, { flexDirection: "column", paddingX: 1, height: Math.max(1, (stdout.rows ?? 30) - 1), overflowY: "hidden", children: [copyMode && _jsx(Text, { color: theme.accent, children: "Copy mode \u00B7 drag to select \u00B7 Esc / Ctrl+S resumes" }), _jsx(Box, { ref: headerSection, flexShrink: 0, children: _jsx(ChatHeader, { title: headerTitle, mode: controller.queries.sessionMode, width: width, frozen: displayPaused, identity: `${width}:${state.sessionId}` }) }), _jsxs(Box, { flexDirection: "column", flexGrow: 1, flexShrink: 1, minHeight: 0, overflowY: "hidden", children: [_jsxs(Box, { ref: bodyPrefix, flexDirection: "column", flexShrink: 0, children: [foreground && _jsxs(Text, { dimColor: true, children: [foreground.label, " \u00B7 Esc / Ctrl+C cancel"] }), _jsx(Frozen, { frozen: statusPaused, identity: state.sessionId ?? "", children: statusNotice && offline === undefined && _jsx(Text, { dimColor: true, wrap: "truncate-end", children: safeText(state.status) }) }), state.lastFailure && offline === undefined && _jsx(Text, { color: theme.colors.error, children: state.lastFailure })] }), state.screen === 'chat' && (peek === undefined ? _jsx(ChatViewport, { rows: visible, showHistoryHint: showHistoryHint, dialogOpen: dialogOpen, historyWindow: !!historyWindow, frozen: displayPaused, identity: `${width}:${state.sessionId}:${position}:${pageSize}`, boxRef: conversationBox })
1042
1051
  : _jsx(PeekPanel, { source: peek.source, ...(peek.error === undefined ? {} : { error: peek.error }), rows: peekVisible, position: peekPosition, total: peekTotal, width: width, now: Date.now(), boxRef: peekBox }))] }), _jsxs(Box, { flexDirection: "column", flexShrink: 0, children: [notice && _jsx(Text, { dimColor: true, children: safeText(notice) }), composerIntent && _jsx(Text, { color: theme.colors.context, children: composerIntent.hint }), loop && _jsx(LoopStatus, { progress: loop }), peek === undefined && _jsxs(Box, { borderStyle: "round", borderColor: pending ? theme.colors.context : state.online ? theme.accent : theme.border, paddingX: 1, flexDirection: "column", flexShrink: 1, minHeight: 3, children: [_jsx(Box, { flexDirection: "column", flexShrink: 1, minHeight: 0, overflowY: "hidden", children: loopForm && loopRecord ? _jsx(LoopDialog, { record: loopRecord, enabled: state.online && controller.queries.foreground === undefined, onStart: run => startLoopFromForm(loopForm.name, run), onBack: () => { setLoopForm(undefined); setInput('/loop '); setDismissedLoopMenu(undefined); }, onClose: () => setLoopForm(undefined) }, loopForm.name) : queueOpen && !pending ? _jsx(QueueDialog, { queued: queued, rows: stdout.rows ?? 30, width: width, unavailable: !!state.controlError, enabled: !input && foreground === undefined && state.online, canSelect: () => !input && controller.queries.foreground === undefined && controller.state.online && !controller.state.pending.length, onRemove: id => operate(() => controller.actions.removeQueued(id)) }) : removal ? _jsx(RemovalDialog, { removal: removal, enabled: !input && foreground === undefined && state.online, canSelect: () => !input && controller.queries.foreground === undefined && controller.state.online, onCancel: () => setRemoval(undefined), onConfirm: () => operate(async () => { if (await controller.actions.removeTarget(removal))
1043
1052
  setRemoval(undefined); }) }) : models ? _jsx(ModelDialog, { models: models, rows: stdout.rows ?? 30, width: width, enabled: !input && foreground === undefined && state.online, canSelect: () => !input && controller.queries.foreground === undefined && controller.state.online, onChoose: (provider, model, effort) => operate(async () => { if (await controller.actions.selectModel(provider, model, effort))
1044
- setModelPanel(undefined); }), onOpen: (provider, model) => setModelPanel({ catalog: models.catalog, provider, model }), onBack: () => setModelPanel({ catalog: models.catalog }), onClose: () => setModelPanel(undefined) }) : searchResults ? _jsx(SearchResultsDialog, { query: searchResults.query, items: searchResults.items, hasMore: searchResults.hasMore, width: width, enabled: !input && foreground === undefined, canSelect: () => !input && controller.queries.foreground === undefined, onOpen: sessionId => operate(() => openSearchSession(sessionId, searchResults.query)), onClose: () => setSearchPanel(undefined) }) : state.screen === 'workspaces' || state.screen === 'sessions' ? _jsx(PickerScreen, { title: state.screen === 'workspaces' ? 'Choose workspace' : state.showAllSessions ? 'Choose session · All workspaces' : 'Choose session', identity: `${state.screen}:${state.workspaceId ?? ''}`, choices: choices, width: pickerWidth,
1053
+ setModelPanel(undefined); }), onOpen: (provider, model) => setModelPanel({ catalog: models.catalog, provider, model }), onBack: () => setModelPanel({ catalog: models.catalog }), onClose: () => setModelPanel(undefined) }) : searchResults ? _jsx(SearchResultsDialog, { query: searchResults.query, items: searchResults.items, hasMore: searchResults.hasMore, width: width, enabled: !input && foreground === undefined, canSelect: () => !input && controller.queries.foreground === undefined, onOpen: sessionId => operate(() => openSearchSession(sessionId, searchResults.query)), onClose: () => setSearchPanel(undefined) }) : pickerScreen ? offline === undefined ? _jsx(PickerScreen, { title: state.screen === 'workspaces' ? 'Choose workspace' : state.showAllSessions ? 'Choose session · All workspaces' : 'Choose session', identity: `${state.screen}:${state.workspaceId ?? ''}`, choices: choices, width: pickerWidth,
1045
1054
  // A rollup of badges is read through the key beside it; spelled-out states need no key, and a
1046
1055
  // terminal too narrow for the whole key gets the badges alone rather than half a legend.
1047
- legend: state.screen === 'workspaces' && rollupStyle === 'badges' && pickerWidth >= 40 ? ROLLUP_LEGEND : undefined, enabled: state.online && foreground === undefined && !input, canSelect: () => !input && controller.state.online && controller.queries.foreground === undefined }) : _jsxs(_Fragment, { children: [thoughtList && _jsx(ThoughtsDialog, { identity: `thoughts:${state.sessionId}`, options: thoughtOptions, empty: !thoughtEntries?.length && !liveThought, rows: stdout.rows ?? 30, enabled: !input && foreground === undefined, canSelect: () => !input && controller.queries.foreground === undefined }), promptsOpen && state.screen === 'chat' && _jsx(PromptsDialog, { identity: "prompts", prompts: controller.queries.prompts, error: controller.queries.promptsError, width: width, enabled: !input && foreground === undefined, canSelect: () => !input && controller.queries.foreground === undefined, onChoose: text => { setInput(text); openPrompts(false); }, onEdit: prompt => {
1056
+ legend: state.screen === 'workspaces' && rollupStyle === 'badges' && pickerWidth >= 40 ? ROLLUP_LEGEND : undefined, enabled: state.online && foreground === undefined && !input, canSelect: () => !input && controller.state.online && controller.queries.foreground === undefined })
1057
+ : _jsx(OfflinePanel, { guidance: offline }) : _jsxs(_Fragment, { children: [thoughtList && _jsx(ThoughtsDialog, { identity: `thoughts:${state.sessionId}`, options: thoughtOptions, empty: !thoughtEntries?.length && !liveThought, rows: stdout.rows ?? 30, enabled: !input && foreground === undefined, canSelect: () => !input && controller.queries.foreground === undefined }), promptsOpen && state.screen === 'chat' && _jsx(PromptsDialog, { identity: "prompts", prompts: controller.queries.prompts, error: controller.queries.promptsError, width: width, enabled: !input && foreground === undefined, canSelect: () => !input && controller.queries.foreground === undefined, onChoose: text => { setInput(text); openPrompts(false); }, onEdit: prompt => {
1048
1058
  setComposerIntent({
1049
1059
  hint: 'Editing saved prompt · Enter saves · Esc cancels',
1050
1060
  emptyNotice: 'Type the prompt text; Enter then saves it',
@@ -2,6 +2,7 @@ import { type ObjectValue } from '../../json.ts';
2
2
  import { type Message, type SavedPrompt } from '../../contracts.ts';
3
3
  import type { HistorySearch, RemovalTarget } from '../../contracts.ts';
4
4
  import type { QueuedInput } from '../../contracts.ts';
5
+ import type { OfflineGuidance } from '../offline.ts';
5
6
  import { type Choice } from './picker.tsx';
6
7
  /** Pending host input with explicit removal.
7
8
  * @param props - Queue contents, geometry and availability.
@@ -91,6 +92,17 @@ export declare function PickerScreen({ title, identity, choices, enabled, canSel
91
92
  /** Marker key shown above the list, for a rollup too compact to spell its states out. */
92
93
  legend?: string;
93
94
  }): import("react").JSX.Element;
95
+ /** What the startup picker shows while the host is unreachable.
96
+ *
97
+ * An empty list with a raw socket error beside it answers neither "what is wrong" nor "what do I do",
98
+ * so the picker is replaced by the state and the one command that resolves it. This is a startup
99
+ * surface only: a conversation keeps its transcript and lets the status bar report the loss.
100
+ * @param props - Guidance block built from the connection's published state.
101
+ * @returns The offline guidance panel.
102
+ */
103
+ export declare function OfflinePanel({ guidance }: {
104
+ guidance: OfflineGuidance;
105
+ }): import("react").JSX.Element;
94
106
  /** Newest-first reasoning summaries with the preceding user prompt.
95
107
  * @param props - Loaded entries and lazy paging action.
96
108
  * @returns The reasoning list.
@@ -86,6 +86,18 @@ export function SearchResultsDialog({ query, items, hasMore, width, enabled, can
86
86
  export function PickerScreen({ title, identity, choices, enabled, canSelect, width, legend }) {
87
87
  return _jsxs(Box, { flexDirection: "column", marginY: 1, children: [_jsx(Text, { bold: true, children: title }), legend !== undefined && _jsx(Text, { dimColor: true, wrap: "truncate-end", children: legend }), _jsx(Picker, { choices: choices, enabled: enabled, canSelect: canSelect, width: width }, identity)] });
88
88
  }
89
+ /** What the startup picker shows while the host is unreachable.
90
+ *
91
+ * An empty list with a raw socket error beside it answers neither "what is wrong" nor "what do I do",
92
+ * so the picker is replaced by the state and the one command that resolves it. This is a startup
93
+ * surface only: a conversation keeps its transcript and lets the status bar report the loss.
94
+ * @param props - Guidance block built from the connection's published state.
95
+ * @returns The offline guidance panel.
96
+ */
97
+ export function OfflinePanel({ guidance }) {
98
+ const theme = useTheme();
99
+ return _jsxs(Box, { flexDirection: "column", marginY: 1, children: [_jsx(Text, { bold: true, color: theme.status.offline, wrap: "truncate-end", children: safeText(guidance.title) }), guidance.lines.map((line, index) => _jsx(Text, { wrap: "truncate-end", color: line.startsWith(' ') ? theme.colors.tool : undefined, children: safeText(line) }, index)), guidance.detail !== undefined && _jsx(Text, { dimColor: true, wrap: "truncate-end", children: safeText(guidance.detail) })] });
100
+ }
89
101
  /** Newest-first reasoning summaries with the preceding user prompt.
90
102
  * @param props - Loaded entries and lazy paging action.
91
103
  * @returns The reasoning list.
@@ -0,0 +1,38 @@
1
+ /** What the startup picker shows when the host it needs is not answering.
2
+ *
3
+ * `dsht` deliberately never starts Harness, so an unreachable host is an operator problem with a
4
+ * known fix. This module turns the connection's published status into a short, accurate block: the
5
+ * state first, then the one command that resolves it. It is pure text, so the wording is testable on
6
+ * its own and the rendering belongs to `dialogs/`.
7
+ */
8
+ /** The command that starts the host this client talks to. */
9
+ export declare const HOST_COMMAND = "npx @deepseek-ai/dsh web";
10
+ /** What the startup picker shows while the host cannot be reached. */
11
+ export interface OfflineGuidance {
12
+ /** Which host, and which of its three states the connection published. */
13
+ readonly title: string;
14
+ /** What happened and what to do about it, in reading order. */
15
+ readonly lines: readonly string[];
16
+ /** The raw transport failure, rendered dim under the guidance rather than as the message. */
17
+ readonly detail?: string;
18
+ }
19
+ /** The `DSH_URL` export that carries a first-run token for one host.
20
+ *
21
+ * `dsh web` prints its URL with a `token` parameter, and that printed line is what a first run
22
+ * exports unchanged; a host URL that already carries query parameters keeps its own separator.
23
+ * @param base - Host URL this client targets.
24
+ * @returns One `export DSH_URL=…` line.
25
+ */
26
+ export declare function dshUrlLine(base: string): string;
27
+ /** Build the startup guidance for one connection state.
28
+ *
29
+ * The first attempt is named as connecting rather than as offline: a client that has not finished
30
+ * trying yet has not learned that anything is wrong, and saying "offline" then would be a guess.
31
+ * @param source - Published connection status, host URL and last failure.
32
+ * @returns The guidance block; never empty, because the caller renders it only while offline.
33
+ */
34
+ export declare function offlineGuidance(source: {
35
+ status: string;
36
+ base: string;
37
+ lastFailure?: string;
38
+ }): OfflineGuidance;
@@ -0,0 +1,57 @@
1
+ /** What the startup picker shows when the host it needs is not answering.
2
+ *
3
+ * `dsht` deliberately never starts Harness, so an unreachable host is an operator problem with a
4
+ * known fix. This module turns the connection's published status into a short, accurate block: the
5
+ * state first, then the one command that resolves it. It is pure text, so the wording is testable on
6
+ * its own and the rendering belongs to `dialogs/`.
7
+ */
8
+ /** The command that starts the host this client talks to. */
9
+ export const HOST_COMMAND = 'npx @deepseek-ai/dsh web';
10
+ /** The `DSH_URL` export that carries a first-run token for one host.
11
+ *
12
+ * `dsh web` prints its URL with a `token` parameter, and that printed line is what a first run
13
+ * exports unchanged; a host URL that already carries query parameters keeps its own separator.
14
+ * @param base - Host URL this client targets.
15
+ * @returns One `export DSH_URL=…` line.
16
+ */
17
+ export function dshUrlLine(base) {
18
+ const separator = base.includes('?') ? '&' : '?';
19
+ const root = base.includes('?') || base.endsWith('/') ? base : `${base}/`;
20
+ return `export DSH_URL='${root}${separator}token=<token>'`;
21
+ }
22
+ /** Build the startup guidance for one connection state.
23
+ *
24
+ * The first attempt is named as connecting rather than as offline: a client that has not finished
25
+ * trying yet has not learned that anything is wrong, and saying "offline" then would be a guess.
26
+ * @param source - Published connection status, host URL and last failure.
27
+ * @returns The guidance block; never empty, because the caller renders it only while offline.
28
+ */
29
+ export function offlineGuidance(source) {
30
+ const { base, status } = source;
31
+ const detail = source.lastFailure === undefined || source.lastFailure === '' ? undefined : source.lastFailure;
32
+ const url = dshUrlLine(base);
33
+ if (status === 'Login required') {
34
+ return { title: `Login required · ${base}`, lines: [
35
+ 'No token was given, or the saved cookie no longer works.',
36
+ 'Start the host and export the URL it prints, then run dsht again:',
37
+ ` ${url}`,
38
+ 'Or export DSH_TOKEN. Tokens are never saved.',
39
+ ], ...(detail === undefined ? {} : { detail }) };
40
+ }
41
+ if (status === 'Connecting…') {
42
+ return { title: `Connecting… · ${base}`, lines: [
43
+ 'Waiting for DeepSeek Harness at that address.',
44
+ 'If nothing is listening yet, start it there:',
45
+ ` ${HOST_COMMAND}`,
46
+ 'First run only, point dsht at the URL it prints:',
47
+ ` ${url}`,
48
+ ], ...(detail === undefined ? {} : { detail }) };
49
+ }
50
+ return { title: `Host offline · ${base}`, lines: [
51
+ 'DeepSeek Harness is not answering yet.',
52
+ 'Start it on that host; this screen reconnects by itself:',
53
+ ` ${HOST_COMMAND}`,
54
+ 'First run only, point dsht at the URL it prints:',
55
+ ` ${url}`,
56
+ ], ...(detail === undefined ? {} : { detail }) };
57
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@itookit/dsht",
3
- "version": "0.6.3",
3
+ "version": "0.6.7",
4
4
  "type": "module",
5
5
  "description": "Remote-first TUI for DeepSeek Harness with SSH-friendly mobile access and request-level cost tracking",
6
6
  "engines": {