@itookit/dsht 0.3.7 → 0.3.8
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 +2 -2
- package/README.md +3 -2
- package/README.zh.md +3 -2
- package/dist/cli/dsht.js +5 -2
- package/dist/controller/controller.d.ts +6 -1
- package/dist/controller/controller.js +31 -1
- package/dist/session/controller.d.ts +9 -0
- package/dist/session/controller.js +26 -0
- package/dist/session/history.d.ts +21 -1
- package/dist/session/history.js +15 -0
- package/dist/shell/controller.d.ts +67 -0
- package/dist/shell/controller.js +126 -0
- package/dist/shell/index.d.ts +5 -0
- package/dist/shell/index.js +3 -0
- package/dist/shell/runner.d.ts +28 -0
- package/dist/shell/runner.js +108 -0
- package/dist/ui/app.js +26 -7
- package/dist/ui/chat/history-view.js +1 -1
- package/dist/ui/chat/shell-view.d.ts +34 -0
- package/dist/ui/chat/shell-view.js +111 -0
- package/dist/ui/commands/parse.d.ts +5 -0
- package/dist/ui/commands/parse.js +9 -0
- package/dist/ui/theme/index.d.ts +5 -0
- package/dist/ui/theme/index.js +2 -1
- package/package.json +1 -1
package/README.i18n.yaml
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
# Git blob hashes of the reviewed bilingual pair.
|
|
2
|
-
README.md:
|
|
3
|
-
README.zh.md:
|
|
2
|
+
README.md: ce72e2f65a0834f3c58925ab907a5ad39653eca1
|
|
3
|
+
README.zh.md: 432aea7bb61f908845055aa06a1779156ad33258
|
package/README.md
CHANGED
|
@@ -166,7 +166,7 @@ npm start
|
|
|
166
166
|
|
|
167
167
|
Both paths read the same `DSH_URL` and `DSH_TOKEN` variables.
|
|
168
168
|
|
|
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. Creating a session requires a selected workspace.
|
|
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.
|
|
170
170
|
|
|
171
171
|
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
172
|
|
|
@@ -298,6 +298,7 @@ In `/ws` and `/resume` pickers, every row reports one of three user-visible stat
|
|
|
298
298
|
`/search` matches literal text case-insensitively in conversation messages, including older pages; tool-only rows are excluded. Search scans up to 80 messages per request, discards each temporary page, and retains at most 200 short matches, including folded reasoning. A truncated result asks you to refine the query. Opening a match loads a separate page around its sequence; `/latest` releases that window. Esc or Ctrl+C cancels the search. A rare or absent term still requires scanning the full history over HTTP; there is no server-side full-text index for this command. `/history` lists your own prompts from the loaded pages. Record sequences are the numbers shown by these pickers. `/ssearch` and `/wsearch` call `session/search`, which searches current user/assistant message content and returns at most 20 sessions, snippets, and a truncation flag; it exposes neither a result cursor nor matching record sequences. Workspace filtering happens after that global limit, so a truncated workspace result can omit matches. The UI warns when results are incomplete; refine the query. Selecting a session loads its history and offers matching messages for the jump. These operations use HTTP and never scan the host configuration directory.
|
|
299
299
|
|
|
300
300
|
↑/↓ or Ctrl+P/N recalls previously submitted prompts and slash commands without sending them; Enter submits the recalled text. A reading panel that fits the screen leaves the arrows with this history, a panel that has to scroll takes them for itself, and Ctrl+P/N reach the history from any panel. Moving past the newest entry restores the unsent draft. Editing recalled text starts a new draft, and switching sessions clears an unsent one. Recall keeps the selected session's user prompts — up to 2,000 entries and approximately 512 KiB of text — and never writes a separate history file; switching sessions releases them. It folds those prompts from the session records as they arrive, so it covers prompts from before this client connected rather than only the window that happened to load. Opening a session also walks the earlier history in the background, keeping only prompts rather than loading those pages into the conversation, so the whole session's prompt list is available from the start. Reaching its oldest retained prompt first refills from the already-loaded conversation at no request cost, and only then fetches the page before the window, inside one bounded loop that skips tool-only pages so the key cannot stall; the fetched page also stays in the conversation above the composer. Eviction therefore bounds memory without deciding reachability: an evicted prompt is either still loaded or still on the host. Consecutive duplicates are merged, oversized entries are skipped, and question or approval answers are excluded. Question options and completion menus keep arrow navigation; workspace/session lists use arrows when the composer is empty, with Ctrl+P/N available for recall.
|
|
301
|
+
`!command` runs that command on the machine this client is on — not on the host the agent works in — and prints the command and its output inline in the conversation, where it stays in place and scrolls away with the history; nothing is sent to the model and nothing is written to disk. Output is capped at 200 lines and 64 KiB per command, only the last 20 commands are kept, and the number of dropped lines is stated in the block. One command runs at a time; Esc, or Ctrl+C on an empty prompt, stops it by killing its whole process group, so pipelines and background children die with it; a command that needs a terminal of its own, such as `vim`, cannot work. `--no-shell` or `DSHT_NO_SHELL=1` disables the prefix.
|
|
301
302
|
|
|
302
303
|
Within each User group, only the first assistant prose or reasoning message shows an Assistant heading. Later messages and live output reuse that heading across tool results and Context messages. A newly loaded history window starts its own visible group; message sequences, tool status, search, and reasoning expansion remain independent.
|
|
303
304
|
|
|
@@ -426,7 +427,7 @@ This repository publishes one public package, `@itookit/dsht`, from the `mushuan
|
|
|
426
427
|
|
|
427
428
|
| Field | Value |
|
|
428
429
|
| --- | --- |
|
|
429
|
-
| Name and version | `@itookit/dsht` `0.3.
|
|
430
|
+
| Name and version | `@itookit/dsht` `0.3.8` |
|
|
430
431
|
| Executable | `dsht`, or `npx @itookit/dsht` without installing |
|
|
431
432
|
| Library entries | `@itookit/dsht` and `@itookit/dsht/auth` |
|
|
432
433
|
| Author | lizlok@gmail.com |
|
package/README.zh.md
CHANGED
|
@@ -166,7 +166,7 @@ npm start
|
|
|
166
166
|
|
|
167
167
|
两种方式读取相同的 `DSH_URL` 和 `DSH_TOKEN` 变量。
|
|
168
168
|
|
|
169
|
-
使用 ↑/↓ 和 Enter 选择工作区,然后选择已有会话或 **New session**。**All sessions** 同时显示未归属注册工作区的会话。**Add workspace (this directory)** 直接注册 `dsht` 自身所在的目录,只在服务端尚未注册它时出现;**Add workspace (host directory)** 接收服务端已有目录的绝对路径,该路径可能与本机文件系统不同,按 Esc
|
|
169
|
+
使用 ↑/↓ 和 Enter 选择工作区,然后选择已有会话或 **New session**。**All sessions** 同时显示未归属注册工作区的会话。**Add workspace (this directory)** 直接注册 `dsht` 自身所在的目录,只在服务端尚未注册它时出现;**Add workspace (host directory)** 接收服务端已有目录的绝对路径,该路径可能与本机文件系统不同,按 Esc 可以退回选择器。在已注册的工作区目录里启动时,直接选中该工作区而不再显示选择器(会话列表里的 `←` 仍可切到别的工作区);命令行给出会话 ID 时依旧直接打开。新建会话前必须选择工作区。
|
|
170
170
|
|
|
171
171
|
首次登录通过 `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
172
|
|
|
@@ -298,6 +298,7 @@ Enter 提交消息:代理处于 Working 时自动作为下一个 step 的转
|
|
|
298
298
|
`/search` 对对话消息进行不区分大小写的字面文本匹配,包含旧页,排除纯工具行。搜索每次请求最多 80 条消息,扫描后释放临时页,只保留最多 200 条简短命中摘要,包含折叠的思考;结果截断时提示缩小查询范围。选择命中项只加载其序号附近的独立页面,`/latest` 释放该窗口。Esc 或 Ctrl+C 可取消搜索。稀有词或无匹配查询仍需通过 HTTP 扫描全部历史,此命令尚无服务端全文索引。`/history` 只列出已加载页面中自己的提示词,选择器显示的数字就是记录序号。`/ssearch` 与 `/wsearch` 调用 `session/search`,服务端搜索当前用户/助手消息内容,最多返回 20 个会话、摘要和截断标记,没有结果分页游标或命中记录序号。工作区筛选在全局数量限制之后进行,因此截断时可能漏掉工作区内的匹配会话;界面会提示结果不完整,可缩小查询范围。选择会话后加载其历史,再选择匹配消息跳转。所有操作均通过 HTTP 完成,不扫描服务端配置目录。
|
|
299
299
|
|
|
300
300
|
↑/↓ 或 Ctrl+P/N 回填之前提交的提示词和 slash 命令,按 Enter 才提交。一屏放得下的阅读面板会把方向键留给该历史,需要滚动的面板才接管方向键,而 Ctrl+P/N 在任何面板打开时都能回填。向下越过最新记录时恢复未发送草稿;编辑回填内容后开始新的草稿,切换会话会清空未发送的草稿。回填按当前会话保留其 user prompt,最多 2,000 条、约 512 KiB 文本,且不写入独立历史文件;切换会话即释放。它在记录到达时增量折叠这些提示词,因此覆盖本客户端连接之前的提示词,而不只是碰巧加载的那个窗口。打开会话后它还会在后台把更早的历史翻一遍,只提取提示词、不把这些页读进上方对话,因此从会话开始就持有整个会话的提示词列表。走到索引里最旧一条时,先用已加载的对话补回被预算淘汰的提示词,这一步不发请求;只有窗口也用尽才取回窗口之前的一页,并在同一次有界循环里跳过整页没有 User 消息的页,因此按键不会被工具页卡住;取回的那页也会留在输入框上方的对话里。于是淘汰只约束内存、不决定可达性:被淘汰的提示词要么仍在窗口内,要么仍在宿主上。连续重复输入合并,超大输入跳过,提问和审批回答不记入历史。提问选项与补全菜单优先使用箭头;工作区/会话列表在输入框为空时使用箭头选择,可用 Ctrl+P/N 调出输入历史。
|
|
301
|
+
`!命令` 在这台运行客户端的机器上执行——不是 agent 所在的宿主——并把命令行与输出内联打印在对话里、留在它发生的位置随历史一起滚走;不会发给模型,也不写入磁盘。每条命令的输出上限为 200 行与 64 KiB,只保留最近 20 条命令,被丢弃的行数会在块内注明。同一时刻只运行一条命令;Esc(或输入框为空时 Ctrl+C)会终止其整个进程组来停止它,因此管道与后台子进程一起结束;需要独占终端的命令(如 `vim`)无法工作。`--no-shell` 或 `DSHT_NO_SHELL=1` 关闭该前缀。
|
|
301
302
|
|
|
302
303
|
每条 User 消息之后,只在第一段助手正文或思考前显示 Assistant 标题;后续消息及流式输出沿用分组,工具结果和 Context 消息不重置分组。当前加载的历史窗口从自身起点建立可见分组,消息序号、工具状态、搜索和思考展开仍各自保留。
|
|
303
304
|
|
|
@@ -426,7 +427,7 @@ Slash 命令在选择器和对话输入框中均可使用。输入 `/` 会显示
|
|
|
426
427
|
|
|
427
428
|
| 字段 | 值 |
|
|
428
429
|
| --- | --- |
|
|
429
|
-
| 名称与版本 | `@itookit/dsht` `0.3.
|
|
430
|
+
| 名称与版本 | `@itookit/dsht` `0.3.8` |
|
|
430
431
|
| 可执行命令 | `dsht`,不安装时用 `npx @itookit/dsht` |
|
|
431
432
|
| 库入口 | `@itookit/dsht` 和 `@itookit/dsht/auth` |
|
|
432
433
|
| 作者 | lizlok\@gmail.com |
|
package/dist/cli/dsht.js
CHANGED
|
@@ -26,6 +26,7 @@ With no command, choose a workspace and session interactively.
|
|
|
26
26
|
--history-mb <n> Soft history payload budget in MiB (default 16)
|
|
27
27
|
--memory-log <path> Append runtime memory samples; a failing log stops itself
|
|
28
28
|
--no-memory-log Disable the runtime memory log (default: enabled)
|
|
29
|
+
--no-shell Disable ! local commands (DSHT_NO_SHELL=1)
|
|
29
30
|
--json Print machine-readable list output
|
|
30
31
|
--help Show this help
|
|
31
32
|
|
|
@@ -33,6 +34,7 @@ The default host is http://127.0.0.1:3080.
|
|
|
33
34
|
First login: export DSH_TOKEN, or export DSH_URL as the URL printed by dsh web.
|
|
34
35
|
Cookies are saved per server origin and reused on later starts. Tokens are never saved.
|
|
35
36
|
/cost shows the session and today CNY estimates.
|
|
37
|
+
!command runs on this machine, not on the host, and prints its output in the transcript.
|
|
36
38
|
DSHT_CONFIG_DIR overrides the prices.json directory; DSHT_STATE_DIR overrides usage storage.
|
|
37
39
|
The memory log defaults to <state>/memory.log; DSHT_MEMORY_LOG sets another path or 'off'.
|
|
38
40
|
prices.json overrides the shipped rates and is seeded on first use; every scan re-decides the
|
|
@@ -47,7 +49,7 @@ async function main() {
|
|
|
47
49
|
url: { type: 'string', default: process.env.DSH_URL ?? 'http://127.0.0.1:3080' },
|
|
48
50
|
'history-records': { type: 'string' }, 'history-mb': { type: 'string' },
|
|
49
51
|
workspace: { type: 'string' }, session: { type: 'string' }, 'auth-dir': { type: 'string' }, json: { type: 'boolean' }, help: { type: 'boolean' },
|
|
50
|
-
'memory-log': { type: 'string' }, 'no-memory-log': { type: 'boolean' },
|
|
52
|
+
'memory-log': { type: 'string' }, 'no-memory-log': { type: 'boolean' }, 'no-shell': { type: 'boolean' },
|
|
51
53
|
} });
|
|
52
54
|
if (values.help) {
|
|
53
55
|
process.stdout.write(HELP);
|
|
@@ -93,7 +95,8 @@ async function main() {
|
|
|
93
95
|
await costs.load();
|
|
94
96
|
if (!process.stdin.isTTY || !process.stdout.isTTY)
|
|
95
97
|
throw new Error('Interactive mode requires a terminal. Use list workspaces or list sessions for scripts.');
|
|
96
|
-
const
|
|
98
|
+
const shellEnabled = !values['no-shell'] && process.env.DSHT_NO_SHELL !== '1';
|
|
99
|
+
const controller = new Controller(url, token, values.session, undefined, client => login(client, token, store), costs, limits, memoryLogPath(stateRoot, values['memory-log'], values['no-memory-log']), undefined, shellEnabled);
|
|
97
100
|
const app = mount(controller);
|
|
98
101
|
const terminate = () => app.unmount();
|
|
99
102
|
process.once('SIGTERM', terminate);
|
|
@@ -11,6 +11,7 @@ import { CostController } from '../cost/controller.ts';
|
|
|
11
11
|
import { ConnectionController, type ConnectionListener } from './connection.ts';
|
|
12
12
|
import { MemoryLog } from './memory-log.ts';
|
|
13
13
|
import { type ControllerStore, type State } from '../state.ts';
|
|
14
|
+
import { ShellController } from '../shell/index.ts';
|
|
14
15
|
import type { HistorySearch, RemovalTarget } from '../session/types.ts';
|
|
15
16
|
import type { ComposerState, InteractionState, ModelState, OptionState, PanelState, ReferenceState, ViewState } from '../session/info.ts';
|
|
16
17
|
import type { Reasoning } from '../session/history.ts';
|
|
@@ -38,11 +39,15 @@ export declare class Controller implements ControllerStore, ConnectionListener {
|
|
|
38
39
|
readonly cost: CostController | undefined;
|
|
39
40
|
/** Bounded runtime memory samples; present only when a log path was supplied. */
|
|
40
41
|
readonly memoryLog: MemoryLog | undefined;
|
|
42
|
+
/** Local `!` commands, run on this machine and shown inline in the transcript. */
|
|
43
|
+
readonly shell: ShellController;
|
|
41
44
|
private readonly observers;
|
|
42
45
|
private selector;
|
|
43
46
|
constructor(base: string, token: string | undefined, initialSession?: string | undefined, makeClient?: () => Client, authenticate?: (client: Client) => Promise<void>, costs?: CostLedger | undefined, historyLimits?: HistoryLimits, memoryLogPath?: string | undefined,
|
|
44
47
|
/** Directory this client runs in, offered as a workspace when the host has not registered it. */
|
|
45
|
-
localDirectory?: string
|
|
48
|
+
localDirectory?: string,
|
|
49
|
+
/** Whether `!` may run local commands; the CLI disables it with `--no-shell`. */
|
|
50
|
+
shellEnabled?: boolean);
|
|
46
51
|
/** React-compatible state subscription. */
|
|
47
52
|
subscribe: (listener: () => void) => (() => void);
|
|
48
53
|
/** Snapshot identity changes only when the controller publishes. */
|
|
@@ -13,6 +13,20 @@ import { ConnectionController } from "./connection.js";
|
|
|
13
13
|
import { MemoryLog } from "./memory-log.js";
|
|
14
14
|
import { clearReactMeasures, measureCount } from "./perf-measures.js";
|
|
15
15
|
import { initialState } from "../state.js";
|
|
16
|
+
import { ShellController } from "../shell/index.js";
|
|
17
|
+
/** Environment for a local `!` command: this client's variables without its credentials.
|
|
18
|
+
*
|
|
19
|
+
* `DSH_URL` is removed as well as the token, because the URL form the README documents can carry a
|
|
20
|
+
* token in its query string. Commands therefore run with the operator's environment, not this
|
|
21
|
+
* client's session.
|
|
22
|
+
* @returns A copy of the environment safe to hand to a child process.
|
|
23
|
+
*/
|
|
24
|
+
function shellEnv() {
|
|
25
|
+
const env = { ...process.env };
|
|
26
|
+
delete env.DSH_TOKEN;
|
|
27
|
+
delete env.DSH_URL;
|
|
28
|
+
return env;
|
|
29
|
+
}
|
|
16
30
|
/** Application facade over the domain controllers; the UI owns only this object.
|
|
17
31
|
*
|
|
18
32
|
* State lives here, connection generations live in `connection`, the selected session and its
|
|
@@ -36,11 +50,15 @@ export class Controller {
|
|
|
36
50
|
cost;
|
|
37
51
|
/** Bounded runtime memory samples; present only when a log path was supplied. */
|
|
38
52
|
memoryLog;
|
|
53
|
+
/** Local `!` commands, run on this machine and shown inline in the transcript. */
|
|
54
|
+
shell;
|
|
39
55
|
observers = new Set();
|
|
40
56
|
selector = 0;
|
|
41
57
|
constructor(base, token, initialSession, makeClient = () => new Client(base), authenticate = client => client.authenticate(token ?? ''), costs, historyLimits = DEFAULT_HISTORY_LIMITS, memoryLogPath,
|
|
42
58
|
/** Directory this client runs in, offered as a workspace when the host has not registered it. */
|
|
43
|
-
localDirectory = process.cwd()
|
|
59
|
+
localDirectory = process.cwd(),
|
|
60
|
+
/** Whether `!` may run local commands; the CLI disables it with `--no-shell`. */
|
|
61
|
+
shellEnabled = true) {
|
|
44
62
|
this.base = base;
|
|
45
63
|
this.initialSession = initialSession;
|
|
46
64
|
this.costs = costs;
|
|
@@ -50,6 +68,12 @@ export class Controller {
|
|
|
50
68
|
const options = { base, token, initialSession, makeClient, authenticate };
|
|
51
69
|
this.connection = new ConnectionController(this, options, this);
|
|
52
70
|
this.session = new SessionController(this, this.connection, this.connection, historyLimits);
|
|
71
|
+
this.shell = new ShellController({
|
|
72
|
+
publish: () => this.update({}),
|
|
73
|
+
cwd: () => this.localDirectory,
|
|
74
|
+
env: () => shellEnv(),
|
|
75
|
+
anchor: () => this.state.session.record.readThrough,
|
|
76
|
+
}, shellEnabled);
|
|
53
77
|
this.catalog = new CatalogController(this, this.connection);
|
|
54
78
|
if (costs)
|
|
55
79
|
this.cost = new CostController(costs, {
|
|
@@ -95,6 +119,7 @@ export class Controller {
|
|
|
95
119
|
start() { this.connection.start(); this.memoryLog?.start(); }
|
|
96
120
|
/** Cancel retries and HTTP, close the socket, and release session and catalog work. */
|
|
97
121
|
async stop() {
|
|
122
|
+
await this.shell.stop();
|
|
98
123
|
await this.connection.stop();
|
|
99
124
|
await this.session.settle();
|
|
100
125
|
await this.catalog.settle();
|
|
@@ -196,6 +221,11 @@ export class Controller {
|
|
|
196
221
|
this.update({ online: true, status: 'Connected', error: '', pending: [] });
|
|
197
222
|
const screen = this.state.screen;
|
|
198
223
|
await this.session.showPicker(screen === 'sessions' ? 'sessions' : 'workspaces');
|
|
224
|
+
// Starting inside a registered workspace's directory already answers the first question, so the
|
|
225
|
+
// reader lands on that workspace's sessions instead of a list they would pick from by hand.
|
|
226
|
+
if (screen !== 'sessions' && !this.initialSession && this.session.adoptLocalWorkspace(this.localDirectory) !== undefined) {
|
|
227
|
+
this.update({ status: 'Workspace from this directory · ← to switch' });
|
|
228
|
+
}
|
|
199
229
|
const sessionId = this.state.sessionId ?? this.initialSession;
|
|
200
230
|
if (sessionId && (screen === 'chat' || this.initialSession && !this.state.sessionId))
|
|
201
231
|
await this.session.selectSession(sessionId);
|
|
@@ -121,6 +121,15 @@ export declare class SessionController {
|
|
|
121
121
|
switchSession(query?: string): Promise<void>;
|
|
122
122
|
/** Prompt for a host path without starting a local agent. */
|
|
123
123
|
enterPath(): void;
|
|
124
|
+
/** Adopt the workspace whose registered path contains the directory this client runs in.
|
|
125
|
+
*
|
|
126
|
+
* Longest path wins, so a workspace nested in another is preferred, and the comparison is on whole
|
|
127
|
+
* path segments so `/srv/app-old` cannot match `/srv/app`. A remote host's paths usually differ
|
|
128
|
+
* from the client's, in which case nothing matches and the picker stays exactly as before.
|
|
129
|
+
* @param directory - Directory this client was started in.
|
|
130
|
+
* @returns The adopted workspace's id, or undefined when none matches.
|
|
131
|
+
*/
|
|
132
|
+
adoptLocalWorkspace(directory: string): string | undefined;
|
|
124
133
|
/** Register a host directory and move to its session picker.
|
|
125
134
|
* @param path - Absolute directory path on the host.
|
|
126
135
|
*/
|
|
@@ -297,6 +297,32 @@ export class SessionController {
|
|
|
297
297
|
}
|
|
298
298
|
/** Prompt for a host path without starting a local agent. */
|
|
299
299
|
enterPath() { this.store.update({ screen: 'path' }); }
|
|
300
|
+
/** Adopt the workspace whose registered path contains the directory this client runs in.
|
|
301
|
+
*
|
|
302
|
+
* Longest path wins, so a workspace nested in another is preferred, and the comparison is on whole
|
|
303
|
+
* path segments so `/srv/app-old` cannot match `/srv/app`. A remote host's paths usually differ
|
|
304
|
+
* from the client's, in which case nothing matches and the picker stays exactly as before.
|
|
305
|
+
* @param directory - Directory this client was started in.
|
|
306
|
+
* @returns The adopted workspace's id, or undefined when none matches.
|
|
307
|
+
*/
|
|
308
|
+
adoptLocalWorkspace(directory) {
|
|
309
|
+
const slashed = (value) => value.replace(/\\/g, '/').replace(/\/+$/, '');
|
|
310
|
+
const target = slashed(directory);
|
|
311
|
+
if (target === '')
|
|
312
|
+
return undefined;
|
|
313
|
+
let best;
|
|
314
|
+
for (const workspace of this.store.state.workspaces) {
|
|
315
|
+
const path = slashed(string(workspace.path));
|
|
316
|
+
if (path === '' || (target !== path && !target.startsWith(`${path}/`)))
|
|
317
|
+
continue;
|
|
318
|
+
if (best === undefined || path.length > best.length)
|
|
319
|
+
best = { id: string(workspace.workspaceId), length: path.length };
|
|
320
|
+
}
|
|
321
|
+
if (best === undefined)
|
|
322
|
+
return undefined;
|
|
323
|
+
this.pickWorkspace(best.id);
|
|
324
|
+
return best.id;
|
|
325
|
+
}
|
|
300
326
|
/** Register a host directory and move to its session picker.
|
|
301
327
|
* @param path - Absolute directory path on the host.
|
|
302
328
|
*/
|
|
@@ -3,7 +3,7 @@ import { type MarkdownSpan } from './markdown.ts';
|
|
|
3
3
|
/** Default fold mode; individual sequence overrides are view state, never stored content. */
|
|
4
4
|
export type Reasoning = 'row' | 'full';
|
|
5
5
|
/** Color semantics are independent of the selected terminal palette. */
|
|
6
|
-
export type RowKind = MessagePart['kind'] | 'user' | 'assistant' | 'context' | 'muted';
|
|
6
|
+
export type RowKind = MessagePart['kind'] | 'user' | 'assistant' | 'context' | 'muted' | 'shell';
|
|
7
7
|
/** One visible terminal row; no remote ANSI is allowed into its text. */
|
|
8
8
|
export interface HistoryRow {
|
|
9
9
|
text: string;
|
|
@@ -11,7 +11,27 @@ export interface HistoryRow {
|
|
|
11
11
|
bold?: boolean;
|
|
12
12
|
seq?: number;
|
|
13
13
|
spans?: MarkdownSpan[];
|
|
14
|
+
/** Draw the row on the theme's local-command bar, used by `!` commands. */
|
|
15
|
+
highlight?: boolean;
|
|
14
16
|
}
|
|
17
|
+
/** Wrap text into rows with the per-kind trimming rule.
|
|
18
|
+
* @param text - Text to wrap.
|
|
19
|
+
* @param width - Available terminal columns.
|
|
20
|
+
* @param kind - Part kind, which decides whether wrapped lines are trimmed.
|
|
21
|
+
* @param seq - Durable sequence, for committed parts.
|
|
22
|
+
* @returns The wrapped rows.
|
|
23
|
+
*/
|
|
24
|
+
/** Wrap plain local text into terminal rows, preserving its own whitespace.
|
|
25
|
+
*
|
|
26
|
+
* Command output is aligned by spaces and indented by stack traces, so this never collapses runs of
|
|
27
|
+
* whitespace the way tool summaries do; only the terminal width decides where a row breaks.
|
|
28
|
+
* @param text - Raw text, possibly containing newlines.
|
|
29
|
+
* @param width - Available terminal columns.
|
|
30
|
+
* @param kind - Row kind used for coloring.
|
|
31
|
+
* @param highlight - Whether the rows are a local command line drawn on the command bar.
|
|
32
|
+
* @returns One row per wrapped terminal line; empty text yields one empty row.
|
|
33
|
+
*/
|
|
34
|
+
export declare function plainRows(text: string, width: number, kind: RowKind, highlight?: boolean): HistoryRow[];
|
|
15
35
|
/** Drop all terminal rows and layout metadata for an evicted or inactive transcript.
|
|
16
36
|
* @param transcript - Transcript whose previously returned layout is no longer used.
|
|
17
37
|
*/
|
package/dist/session/history.js
CHANGED
|
@@ -140,6 +140,21 @@ const liveRows = new WeakMap();
|
|
|
140
140
|
* @param seq - Durable sequence, for committed parts.
|
|
141
141
|
* @returns The wrapped rows.
|
|
142
142
|
*/
|
|
143
|
+
/** Wrap plain local text into terminal rows, preserving its own whitespace.
|
|
144
|
+
*
|
|
145
|
+
* Command output is aligned by spaces and indented by stack traces, so this never collapses runs of
|
|
146
|
+
* whitespace the way tool summaries do; only the terminal width decides where a row breaks.
|
|
147
|
+
* @param text - Raw text, possibly containing newlines.
|
|
148
|
+
* @param width - Available terminal columns.
|
|
149
|
+
* @param kind - Row kind used for coloring.
|
|
150
|
+
* @param highlight - Whether the rows are a local command line drawn on the command bar.
|
|
151
|
+
* @returns One row per wrapped terminal line; empty text yields one empty row.
|
|
152
|
+
*/
|
|
153
|
+
export function plainRows(text, width, kind, highlight = false) {
|
|
154
|
+
const columns = Math.max(1, width);
|
|
155
|
+
const wrapped = wrapAnsi(text === '' ? ' ' : text, columns, { hard: true, trim: false });
|
|
156
|
+
return wrapped.split('\n').map(line => ({ text: line, kind, ...(highlight ? { highlight: true } : {}) }));
|
|
157
|
+
}
|
|
143
158
|
function wrapRows(text, width, kind, seq) {
|
|
144
159
|
return wrapAnsi(text, width, { hard: true, trim: !['tool', 'success', 'error'].includes(kind) })
|
|
145
160
|
.split('\n').map(text => ({ text, kind, seq }));
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/** One local command and the output retained for it. */
|
|
2
|
+
export interface ShellBlock {
|
|
3
|
+
id: number;
|
|
4
|
+
command: string;
|
|
5
|
+
/** Retained output lines, oldest first. */
|
|
6
|
+
lines: string[];
|
|
7
|
+
/** Lines dropped from the front because the block exceeded its budget. */
|
|
8
|
+
dropped: number;
|
|
9
|
+
/** Durable sequence that was newest when the command started; the block is shown after it. */
|
|
10
|
+
anchor: number;
|
|
11
|
+
status: 'running' | 'exited';
|
|
12
|
+
/** Exit code, once the command ended; null when a signal ended it. */
|
|
13
|
+
code?: number | null;
|
|
14
|
+
signal?: string | null;
|
|
15
|
+
startedAt: number;
|
|
16
|
+
endedAt?: number;
|
|
17
|
+
}
|
|
18
|
+
/** What one shell controller needs from its owner. */
|
|
19
|
+
export interface ShellHost {
|
|
20
|
+
/** Repaint after output or a status change. */
|
|
21
|
+
publish(): void;
|
|
22
|
+
/** Client working directory the command runs in. */
|
|
23
|
+
cwd(): string;
|
|
24
|
+
/** Environment for the child, already stripped of client credentials. */
|
|
25
|
+
env(): NodeJS.ProcessEnv;
|
|
26
|
+
/** Newest durable sequence of the selected session, so a block stays where it happened. */
|
|
27
|
+
anchor(): number;
|
|
28
|
+
}
|
|
29
|
+
/** Owns the `!` commands of this client process: one at a time, bounded, killable. */
|
|
30
|
+
export declare class ShellController {
|
|
31
|
+
private readonly host;
|
|
32
|
+
readonly enabled: boolean;
|
|
33
|
+
private readonly blocks;
|
|
34
|
+
private readonly bytes;
|
|
35
|
+
private nextId;
|
|
36
|
+
private task;
|
|
37
|
+
private abort;
|
|
38
|
+
private lastPublish;
|
|
39
|
+
constructor(host: ShellHost, /** Whether `!` is allowed at all. */ enabled?: boolean);
|
|
40
|
+
/** Runs in creation order, oldest first; the newest is what the transcript shows at its end. */
|
|
41
|
+
get runs(): readonly ShellBlock[];
|
|
42
|
+
/** Whether a command is still running. */
|
|
43
|
+
get running(): boolean;
|
|
44
|
+
/** Start one command.
|
|
45
|
+
*
|
|
46
|
+
* One command runs at a time: a second `!` while the first is live is refused rather than queued,
|
|
47
|
+
* because the transcript shows a single result block and the reader can stop the first with Ctrl+C.
|
|
48
|
+
* @param command - Command line typed after `!`.
|
|
49
|
+
* @returns The block created for it.
|
|
50
|
+
*/
|
|
51
|
+
start(command: string): ShellBlock;
|
|
52
|
+
/** Stop the running command: its process group gets SIGTERM, then SIGKILL after a grace period.
|
|
53
|
+
* @returns Whether a run was stopped.
|
|
54
|
+
*/
|
|
55
|
+
cancel(): boolean;
|
|
56
|
+
/** One run's retained output, with dropped lines made explicit.
|
|
57
|
+
* @param id - Run to read; defaults to the newest.
|
|
58
|
+
* @returns The text, or undefined when the run is unknown.
|
|
59
|
+
*/
|
|
60
|
+
output(id?: number): string | undefined;
|
|
61
|
+
/** Stop the running command and wait for it, so no child outlives this client. */
|
|
62
|
+
stop(): Promise<void>;
|
|
63
|
+
/** Append one output line, trimming the block to its budgets from the front. */
|
|
64
|
+
private append;
|
|
65
|
+
/** Repaint, at most once per interval unless the change is a status change. */
|
|
66
|
+
private publish;
|
|
67
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/** Local shell runs the reader started with `!`, kept as bounded blocks for inline display.
|
|
2
|
+
*
|
|
3
|
+
* A block is the command line plus its retained output. Output is capped by both lines and bytes, so
|
|
4
|
+
* a runaway command can only fill its block, and only the newest blocks are kept. Nothing here is
|
|
5
|
+
* durable: no host record, no session state, no file.
|
|
6
|
+
*/
|
|
7
|
+
import { setTimeout as delay } from 'node:timers/promises';
|
|
8
|
+
import { runShell } from "./runner.js";
|
|
9
|
+
/** Output lines one block keeps before it drops the oldest. */
|
|
10
|
+
const BLOCK_LINES = 200;
|
|
11
|
+
/** Output bytes one block keeps before it drops the oldest. */
|
|
12
|
+
const BLOCK_BYTES = 64 * 1024;
|
|
13
|
+
/** Runs kept for display; the newest always survives. */
|
|
14
|
+
const BLOCK_LIMIT = 20;
|
|
15
|
+
/** Fastest repaint cadence while output streams; status changes always publish. */
|
|
16
|
+
const PUBLISH_INTERVAL_MS = 80;
|
|
17
|
+
/** Owns the `!` commands of this client process: one at a time, bounded, killable. */
|
|
18
|
+
export class ShellController {
|
|
19
|
+
host;
|
|
20
|
+
enabled;
|
|
21
|
+
blocks = [];
|
|
22
|
+
bytes = new WeakMap();
|
|
23
|
+
nextId = 1;
|
|
24
|
+
task;
|
|
25
|
+
abort;
|
|
26
|
+
lastPublish = 0;
|
|
27
|
+
constructor(host, /** Whether `!` is allowed at all. */ enabled = true) {
|
|
28
|
+
this.host = host;
|
|
29
|
+
this.enabled = enabled;
|
|
30
|
+
}
|
|
31
|
+
/** Runs in creation order, oldest first; the newest is what the transcript shows at its end. */
|
|
32
|
+
get runs() { return this.blocks; }
|
|
33
|
+
/** Whether a command is still running. */
|
|
34
|
+
get running() { return this.blocks.some(block => block.status === 'running'); }
|
|
35
|
+
/** Start one command.
|
|
36
|
+
*
|
|
37
|
+
* One command runs at a time: a second `!` while the first is live is refused rather than queued,
|
|
38
|
+
* because the transcript shows a single result block and the reader can stop the first with Ctrl+C.
|
|
39
|
+
* @param command - Command line typed after `!`.
|
|
40
|
+
* @returns The block created for it.
|
|
41
|
+
*/
|
|
42
|
+
start(command) {
|
|
43
|
+
if (!this.enabled)
|
|
44
|
+
throw new Error('Shell commands are disabled (--no-shell or DSHT_NO_SHELL=1)');
|
|
45
|
+
if (this.task)
|
|
46
|
+
throw new Error('A shell command is already running; Ctrl+C stops it');
|
|
47
|
+
const block = { id: this.nextId++, command, lines: [], dropped: 0, status: 'running',
|
|
48
|
+
startedAt: Date.now(), anchor: this.host.anchor() };
|
|
49
|
+
this.blocks.push(block);
|
|
50
|
+
while (this.blocks.length > BLOCK_LIMIT)
|
|
51
|
+
this.blocks.shift();
|
|
52
|
+
this.bytes.set(block, 0);
|
|
53
|
+
const abort = new AbortController();
|
|
54
|
+
this.abort = abort;
|
|
55
|
+
this.publish(true);
|
|
56
|
+
const task = runShell(command, {
|
|
57
|
+
cwd: this.host.cwd(), env: this.host.env(), signal: abort.signal,
|
|
58
|
+
onLine: (line, stream) => this.append(block, line),
|
|
59
|
+
}).then(exit => {
|
|
60
|
+
block.status = 'exited';
|
|
61
|
+
block.code = exit.code;
|
|
62
|
+
block.signal = exit.signal;
|
|
63
|
+
block.endedAt = Date.now();
|
|
64
|
+
}, error => {
|
|
65
|
+
block.status = 'exited';
|
|
66
|
+
block.code = null;
|
|
67
|
+
this.append(block, `! ${error instanceof Error ? error.message : String(error)}`);
|
|
68
|
+
}).finally(() => {
|
|
69
|
+
if (this.abort === abort)
|
|
70
|
+
this.abort = undefined;
|
|
71
|
+
if (this.task === task)
|
|
72
|
+
this.task = undefined;
|
|
73
|
+
this.publish(true);
|
|
74
|
+
});
|
|
75
|
+
this.task = task;
|
|
76
|
+
return block;
|
|
77
|
+
}
|
|
78
|
+
/** Stop the running command: its process group gets SIGTERM, then SIGKILL after a grace period.
|
|
79
|
+
* @returns Whether a run was stopped.
|
|
80
|
+
*/
|
|
81
|
+
cancel() {
|
|
82
|
+
if (!this.abort)
|
|
83
|
+
return false;
|
|
84
|
+
this.abort.abort();
|
|
85
|
+
return true;
|
|
86
|
+
}
|
|
87
|
+
/** One run's retained output, with dropped lines made explicit.
|
|
88
|
+
* @param id - Run to read; defaults to the newest.
|
|
89
|
+
* @returns The text, or undefined when the run is unknown.
|
|
90
|
+
*/
|
|
91
|
+
output(id) {
|
|
92
|
+
const block = id === undefined ? this.blocks.at(-1) : this.blocks.find(item => item.id === id);
|
|
93
|
+
if (!block)
|
|
94
|
+
return undefined;
|
|
95
|
+
const head = block.dropped > 0 ? [`… ${block.dropped} earlier lines dropped …`] : [];
|
|
96
|
+
return [...head, ...block.lines].join('\n');
|
|
97
|
+
}
|
|
98
|
+
/** Stop the running command and wait for it, so no child outlives this client. */
|
|
99
|
+
async stop() {
|
|
100
|
+
this.cancel();
|
|
101
|
+
await this.task;
|
|
102
|
+
while (this.running)
|
|
103
|
+
await delay(20);
|
|
104
|
+
}
|
|
105
|
+
/** Append one output line, trimming the block to its budgets from the front. */
|
|
106
|
+
append(block, line) {
|
|
107
|
+
block.lines.push(line);
|
|
108
|
+
let bytes = (this.bytes.get(block) ?? 0) + line.length * 2;
|
|
109
|
+
while (block.lines.length > BLOCK_LINES || bytes > BLOCK_BYTES) {
|
|
110
|
+
if (block.lines.length === 1)
|
|
111
|
+
break;
|
|
112
|
+
bytes -= block.lines.shift().length * 2;
|
|
113
|
+
block.dropped++;
|
|
114
|
+
}
|
|
115
|
+
this.bytes.set(block, bytes);
|
|
116
|
+
this.publish(false);
|
|
117
|
+
}
|
|
118
|
+
/** Repaint, at most once per interval unless the change is a status change. */
|
|
119
|
+
publish(force) {
|
|
120
|
+
const now = Date.now();
|
|
121
|
+
if (!force && now - this.lastPublish < PUBLISH_INTERVAL_MS)
|
|
122
|
+
return;
|
|
123
|
+
this.lastPublish = now;
|
|
124
|
+
this.host.publish();
|
|
125
|
+
}
|
|
126
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
/** Shell domain: the local `!` command runner and the bounded blocks it produces. */
|
|
2
|
+
export { ShellController } from './controller.ts';
|
|
3
|
+
export type { ShellBlock, ShellHost } from './controller.ts';
|
|
4
|
+
export { runShell } from './runner.ts';
|
|
5
|
+
export type { ShellExit, ShellRunOptions, ShellStream } from './runner.ts';
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/** Which pipe one line arrived on. */
|
|
2
|
+
export type ShellStream = 'stdout' | 'stderr';
|
|
3
|
+
/** How one command ended. */
|
|
4
|
+
export interface ShellExit {
|
|
5
|
+
code: number | null;
|
|
6
|
+
signal: string | null;
|
|
7
|
+
}
|
|
8
|
+
/** One command's execution contract. */
|
|
9
|
+
export interface ShellRunOptions {
|
|
10
|
+
/** Working directory; the client's own directory, not the host's. */
|
|
11
|
+
cwd: string;
|
|
12
|
+
/** Environment for the child; the caller strips client credentials first. */
|
|
13
|
+
env: NodeJS.ProcessEnv;
|
|
14
|
+
/** Cancels the run; the child's process group is terminated. */
|
|
15
|
+
signal: AbortSignal;
|
|
16
|
+
/** Receives every complete line, in arrival order across both pipes. */
|
|
17
|
+
onLine(line: string, stream: ShellStream): void;
|
|
18
|
+
}
|
|
19
|
+
/** Run one command through the operator's shell and stream its lines.
|
|
20
|
+
*
|
|
21
|
+
* Both pipes are merged into one line stream in arrival order. A line longer than the assemble
|
|
22
|
+
* budget is emitted once, truncated, and the remainder is discarded until the next newline, so a
|
|
23
|
+
* command that never emits one cannot grow the client's memory.
|
|
24
|
+
* @param command - Command line, exactly as typed after `!`.
|
|
25
|
+
* @param options - Directory, environment, cancellation and the line sink.
|
|
26
|
+
* @returns How the command ended.
|
|
27
|
+
*/
|
|
28
|
+
export declare function runShell(command: string, options: ShellRunOptions): Promise<ShellExit>;
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/** Running one local shell command for the reader, and nothing else.
|
|
2
|
+
*
|
|
3
|
+
* `!cmd` executes on the machine this client runs on — the operator's laptop or a jump host — never
|
|
4
|
+
* on the host the agent works in. The host's shell belongs to the model's own tools; this module is
|
|
5
|
+
* a separate, local facility, and it is the only place in `src/` allowed to spawn a process.
|
|
6
|
+
*/
|
|
7
|
+
import { spawn } from 'node:child_process';
|
|
8
|
+
/** Longest single output line kept while it is still being assembled. */
|
|
9
|
+
const MAX_LINE_CHARS = 8 * 1024;
|
|
10
|
+
/** How long a cancelled command may ignore SIGTERM before it is killed. */
|
|
11
|
+
const KILL_GRACE_MS = 2_000;
|
|
12
|
+
/** The interactive shell to run under, falling back to `sh` when `$SHELL` is unusable. */
|
|
13
|
+
function shellPath() {
|
|
14
|
+
const chosen = process.env.SHELL;
|
|
15
|
+
return chosen !== undefined && chosen !== '' ? chosen : '/bin/sh';
|
|
16
|
+
}
|
|
17
|
+
/** Terminate one child's whole process group, so pipelines and background children die with it.
|
|
18
|
+
*
|
|
19
|
+
* The child is spawned detached, which gives it its own group; signalling the group is what makes
|
|
20
|
+
* cancellation behave like Ctrl+C in a terminal instead of leaving orphans holding the pipes.
|
|
21
|
+
* @param child - The spawned shell.
|
|
22
|
+
* @param signal - Signal to send the group.
|
|
23
|
+
*/
|
|
24
|
+
function signalGroup(child, signal) {
|
|
25
|
+
const pid = child.pid;
|
|
26
|
+
if (pid === undefined)
|
|
27
|
+
return;
|
|
28
|
+
try {
|
|
29
|
+
process.kill(-pid, signal);
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
try {
|
|
33
|
+
child.kill(signal);
|
|
34
|
+
}
|
|
35
|
+
catch { /* already gone */ }
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
/** Run one command through the operator's shell and stream its lines.
|
|
39
|
+
*
|
|
40
|
+
* Both pipes are merged into one line stream in arrival order. A line longer than the assemble
|
|
41
|
+
* budget is emitted once, truncated, and the remainder is discarded until the next newline, so a
|
|
42
|
+
* command that never emits one cannot grow the client's memory.
|
|
43
|
+
* @param command - Command line, exactly as typed after `!`.
|
|
44
|
+
* @param options - Directory, environment, cancellation and the line sink.
|
|
45
|
+
* @returns How the command ended.
|
|
46
|
+
*/
|
|
47
|
+
export function runShell(command, options) {
|
|
48
|
+
return new Promise(resolve => {
|
|
49
|
+
const child = spawn(shellPath(), ['-c', command], {
|
|
50
|
+
cwd: options.cwd, env: options.env, detached: true, stdio: ['ignore', 'pipe', 'pipe'],
|
|
51
|
+
});
|
|
52
|
+
const carry = { stdout: '', stderr: '' };
|
|
53
|
+
const discarding = { stdout: false, stderr: false };
|
|
54
|
+
let settled = false;
|
|
55
|
+
let graceTimer;
|
|
56
|
+
/** Emit one assembled line, keeping the partial remainder for the next chunk. */
|
|
57
|
+
const feed = (stream, chunk) => {
|
|
58
|
+
carry[stream] += chunk;
|
|
59
|
+
for (;;) {
|
|
60
|
+
const newline = carry[stream].indexOf('\n');
|
|
61
|
+
if (newline < 0)
|
|
62
|
+
break;
|
|
63
|
+
const line = carry[stream].slice(0, newline);
|
|
64
|
+
carry[stream] = carry[stream].slice(newline + 1);
|
|
65
|
+
if (discarding[stream])
|
|
66
|
+
discarding[stream] = false;
|
|
67
|
+
else
|
|
68
|
+
options.onLine(line.replace(/\r$/, ''), stream);
|
|
69
|
+
}
|
|
70
|
+
if (carry[stream].length > MAX_LINE_CHARS) {
|
|
71
|
+
if (!discarding[stream]) {
|
|
72
|
+
options.onLine(`${carry[stream].slice(0, MAX_LINE_CHARS)}…`, stream);
|
|
73
|
+
discarding[stream] = true;
|
|
74
|
+
}
|
|
75
|
+
carry[stream] = '';
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
const flush = (stream) => {
|
|
79
|
+
if (carry[stream] !== '' && !discarding[stream])
|
|
80
|
+
options.onLine(carry[stream].replace(/\r$/, ''), stream);
|
|
81
|
+
carry[stream] = '';
|
|
82
|
+
};
|
|
83
|
+
const finish = (exit) => {
|
|
84
|
+
if (settled)
|
|
85
|
+
return;
|
|
86
|
+
settled = true;
|
|
87
|
+
clearTimeout(graceTimer);
|
|
88
|
+
options.signal.removeEventListener('abort', onAbort);
|
|
89
|
+
flush('stdout');
|
|
90
|
+
flush('stderr');
|
|
91
|
+
resolve(exit);
|
|
92
|
+
};
|
|
93
|
+
const onAbort = () => {
|
|
94
|
+
signalGroup(child, 'SIGTERM');
|
|
95
|
+
graceTimer = setTimeout(() => { signalGroup(child, 'SIGKILL'); }, KILL_GRACE_MS);
|
|
96
|
+
};
|
|
97
|
+
child.stdout?.setEncoding('utf8');
|
|
98
|
+
child.stderr?.setEncoding('utf8');
|
|
99
|
+
child.stdout?.on('data', (chunk) => feed('stdout', chunk));
|
|
100
|
+
child.stderr?.on('data', (chunk) => feed('stderr', chunk));
|
|
101
|
+
child.on('error', error => { options.onLine(`! ${error.message}`, 'stderr'); finish({ code: null, signal: null }); });
|
|
102
|
+
child.on('close', (code, signal) => finish({ code, signal }));
|
|
103
|
+
if (options.signal.aborted)
|
|
104
|
+
onAbort();
|
|
105
|
+
else
|
|
106
|
+
options.signal.addEventListener('abort', onAbort, { once: true });
|
|
107
|
+
});
|
|
108
|
+
}
|
package/dist/ui/app.js
CHANGED
|
@@ -13,6 +13,7 @@ import { CostPanel } from "./dialogs/cost.js";
|
|
|
13
13
|
import { StatusBar } from "./chat/status.js";
|
|
14
14
|
import { ChatHeader } from "./chat/header.js";
|
|
15
15
|
import { ChatViewport } from "./chat/viewport.js";
|
|
16
|
+
import { mergeShellRuns } from "./chat/shell-view.js";
|
|
16
17
|
import { Frozen } from "./frozen.js";
|
|
17
18
|
import { CopyMode } from "./copy-mode.js";
|
|
18
19
|
import { HelpPanel, HistoryDialog, ModelDialog, PickerScreen, QueueDialog, QueuedPreview, RemovalDialog, SearchResultsDialog, ThoughtsDialog } from "./dialogs/index.js";
|
|
@@ -360,6 +361,11 @@ export function App({ controller, panelLifetimeMs = PANEL_LIFETIME_MS, theme = m
|
|
|
360
361
|
operate(() => controller.showPicker('workspaces'));
|
|
361
362
|
return;
|
|
362
363
|
}
|
|
364
|
+
if (key.ctrl && _value === 'c' && input === '' && controller.shell.running) {
|
|
365
|
+
// A local command in the transcript is the most immediate thing Ctrl+C can stop.
|
|
366
|
+
controller.shell.cancel();
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
363
369
|
if (key.ctrl && _value === 'c') {
|
|
364
370
|
// A draft clears first, exactly like a shell prompt; an empty draft still stops or exits.
|
|
365
371
|
if (input !== '') {
|
|
@@ -416,6 +422,11 @@ export function App({ controller, panelLifetimeMs = PANEL_LIFETIME_MS, theme = m
|
|
|
416
422
|
void controller.interrupt(true);
|
|
417
423
|
return;
|
|
418
424
|
}
|
|
425
|
+
if (key.escape && controller.shell.running && input === '' && !panelBlocksKeys && !pending && state.screen === 'chat') {
|
|
426
|
+
// The local command is the most immediate thing Esc can stop; the next press interrupts the agent.
|
|
427
|
+
controller.shell.cancel();
|
|
428
|
+
return;
|
|
429
|
+
}
|
|
419
430
|
if (key.escape && state.screen === 'chat') {
|
|
420
431
|
void controller.interrupt(true);
|
|
421
432
|
}
|
|
@@ -516,6 +527,10 @@ export function App({ controller, panelLifetimeMs = PANEL_LIFETIME_MS, theme = m
|
|
|
516
527
|
case 'queue':
|
|
517
528
|
controller.openQueue(true);
|
|
518
529
|
return;
|
|
530
|
+
case 'shell':
|
|
531
|
+
controller.shell.start(submission.command);
|
|
532
|
+
controller.setScroll(0);
|
|
533
|
+
return;
|
|
519
534
|
case 'newSession':
|
|
520
535
|
await controller.createSession();
|
|
521
536
|
return;
|
|
@@ -663,21 +678,25 @@ export function App({ controller, panelLifetimeMs = PANEL_LIFETIME_MS, theme = m
|
|
|
663
678
|
];
|
|
664
679
|
const layout = useMemo(() => historyLayout(displayTranscript, width, reasoning, reasoningOverrides, liveReasoning), [displayTranscript, displayTranscript.version, width, reasoning, reasoningOverrides, liveReasoning]);
|
|
665
680
|
const { length, first } = layout;
|
|
681
|
+
// Local `!` blocks live at the end of the transcript: not host records, not persisted, but they
|
|
682
|
+
// scroll with the conversation and are counted into its total so the viewport math stays honest.
|
|
683
|
+
const merged = useMemo(() => mergeShellRuns(layout, controller.shell.runs, width), [layout, controller, state.version, width]);
|
|
684
|
+
const totalRows = merged.total;
|
|
666
685
|
const statusNotice = !['Connected', 'Idle', 'Running…', 'Responding…'].includes(state.status);
|
|
667
686
|
const showHistoryHint = dialogOpen || displayTranscript.hasMore || !!historyWindow;
|
|
668
687
|
const pageSize = Math.max(1, conversationRows - (showHistoryHint ? 1 : 0));
|
|
669
|
-
const previousView = useRef({ transcript: displayTranscript, session: state.session.record, count:
|
|
688
|
+
const previousView = useRef({ transcript: displayTranscript, session: state.session.record, count: totalRows, first, folds: reasoningOverrides, liveReasoning });
|
|
670
689
|
const previous = previousView.current;
|
|
671
690
|
const prepended = previous.first !== undefined && first !== undefined && first < previous.first;
|
|
672
691
|
const adjustedScroll = previous.session !== state.session.record ? 0
|
|
673
|
-
: scroll > 0 && previous.transcript === displayTranscript && !prepended && previous.folds === reasoningOverrides && previous.liveReasoning === liveReasoning ? Math.max(0, scroll +
|
|
674
|
-
const maxScroll = Math.max(0,
|
|
692
|
+
: scroll > 0 && previous.transcript === displayTranscript && !prepended && previous.folds === reasoningOverrides && previous.liveReasoning === liveReasoning ? Math.max(0, scroll + totalRows - previous.count) : scroll;
|
|
693
|
+
const maxScroll = Math.max(0, totalRows - pageSize);
|
|
675
694
|
const position = Math.min(adjustedScroll, maxScroll);
|
|
676
695
|
useLayoutEffect(() => {
|
|
677
|
-
previousView.current = { transcript: displayTranscript, session: state.session.record, count:
|
|
696
|
+
previousView.current = { transcript: displayTranscript, session: state.session.record, count: totalRows, first, folds: reasoningOverrides, liveReasoning };
|
|
678
697
|
if (position !== scroll)
|
|
679
698
|
controller.setScroll(position);
|
|
680
|
-
}, [state.session.record, displayTranscript,
|
|
699
|
+
}, [state.session.record, displayTranscript, totalRows, position, scroll, reasoningOverrides, liveReasoning]);
|
|
681
700
|
useLayoutEffect(() => {
|
|
682
701
|
controller.pinHistory(!historyWindow && (position > 0 || thoughtList || historyQuery !== undefined && !contentSearch));
|
|
683
702
|
}, [controller, historyWindow, position, thoughtList, historyQuery, contentSearch, state.session.record]);
|
|
@@ -802,8 +821,8 @@ export function App({ controller, panelLifetimeMs = PANEL_LIFETIME_MS, theme = m
|
|
|
802
821
|
scrollHistory(direction * 3); }, !copyMode && state.screen === 'chat', () => { if (!dialogOpen)
|
|
803
822
|
setCopyMode(true); });
|
|
804
823
|
const trailingGap = dialogOpen && length > 0 && layout.viewport(length - 1, length)[0]?.text === '' ? 1 : 0;
|
|
805
|
-
const end = Math.max(pageSize,
|
|
806
|
-
const visible = useMemo(() =>
|
|
824
|
+
const end = Math.max(pageSize, totalRows - position - trailingGap);
|
|
825
|
+
const visible = useMemo(() => merged.viewport(Math.max(0, end - pageSize), end), [merged, end, pageSize]);
|
|
807
826
|
const liveThought = thoughtList && !historyWindow ? state.session.record.liveParts(width).find(part => part.kind === 'reasoning') : undefined;
|
|
808
827
|
const thoughtEntries = thoughtList ? displayTranscript.thoughts : undefined;
|
|
809
828
|
const thoughtChoices = useMemo(() => [...(thoughtEntries ?? [])].reverse().map(entry => ({
|
|
@@ -12,6 +12,6 @@ import { useTheme } from "../theme/index.js";
|
|
|
12
12
|
*/
|
|
13
13
|
export function HistoryViewport({ rows }) {
|
|
14
14
|
const theme = useTheme();
|
|
15
|
-
return _jsx(Box, { flexDirection: "column", children: rows.map((row, index) => _jsx(Text, { color: theme.colors[row.kind],
|
|
15
|
+
return _jsx(Box, { flexDirection: "column", children: rows.map((row, index) => _jsx(Text, { bold: row.bold, color: row.highlight ? theme.shell.foreground : theme.colors[row.kind], backgroundColor: row.highlight ? theme.shell.background : undefined, children: row.spans?.length ? row.spans.map((span, position) => _jsx(Text, { bold: span.bold, italic: span.italic, underline: span.underline, strikethrough: span.strikethrough, inverse: span.inverse, children: span.text }, position))
|
|
16
16
|
: row.text === '' ? ' ' : row.text }, index)) });
|
|
17
17
|
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { type HistoryRow } from '../../session/history.ts';
|
|
2
|
+
import type { ShellBlock } from '../../shell/index.ts';
|
|
3
|
+
/** The part of a transcript layout this module needs to place a block. */
|
|
4
|
+
export interface RowSource {
|
|
5
|
+
/** Number of host rows, live tail included. */
|
|
6
|
+
length: number;
|
|
7
|
+
/** Host rows in a range. */
|
|
8
|
+
viewport(start: number, end: number): HistoryRow[];
|
|
9
|
+
/** Projected messages in ascending sequence order. */
|
|
10
|
+
messages: readonly {
|
|
11
|
+
seq: number;
|
|
12
|
+
}[];
|
|
13
|
+
/** Row index where each visible message starts. */
|
|
14
|
+
offsets: ReadonlyMap<number, number>;
|
|
15
|
+
}
|
|
16
|
+
/** Rows for one run: the command bar, then its wrapped and indented output.
|
|
17
|
+
* @param run - Block from the shell controller.
|
|
18
|
+
* @param width - Available terminal columns.
|
|
19
|
+
* @returns Rows in display order.
|
|
20
|
+
*/
|
|
21
|
+
export declare function blockRows(run: ShellBlock, width: number): HistoryRow[];
|
|
22
|
+
/** Splice every retained block into a transcript layout.
|
|
23
|
+
*
|
|
24
|
+
* Equal anchors keep creation order, and an anchor older than a block already placed is clamped to
|
|
25
|
+
* it, so the merged stream stays ordered even as history is paged in behind the reader.
|
|
26
|
+
* @param layout - Transcript layout to merge with.
|
|
27
|
+
* @param runs - Blocks from the shell controller, oldest first.
|
|
28
|
+
* @param width - Available terminal columns.
|
|
29
|
+
* @returns The merged row count and a reader over a merged range.
|
|
30
|
+
*/
|
|
31
|
+
export declare function mergeShellRuns(layout: RowSource, runs: readonly ShellBlock[], width: number): {
|
|
32
|
+
total: number;
|
|
33
|
+
viewport(start: number, end: number): HistoryRow[];
|
|
34
|
+
};
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/** Local `!` blocks as transcript rows: a highlighted command bar, then its indented output.
|
|
2
|
+
*
|
|
3
|
+
* A block is spliced into the conversation where it happened — after the newest record the session
|
|
4
|
+
* had when the command started — so it scrolls away like any message instead of sitting pinned to
|
|
5
|
+
* the bottom of the screen.
|
|
6
|
+
*/
|
|
7
|
+
import stringWidth from 'string-width';
|
|
8
|
+
import { plainRows } from "../../session/history.js";
|
|
9
|
+
/** First output row's marker, so a block reads as the command's result. */
|
|
10
|
+
const MARKER = ' ⎿ ';
|
|
11
|
+
/** Rows after the first, aligned under the marker. */
|
|
12
|
+
const GUTTER = ' ';
|
|
13
|
+
/** Rows for one run: the command bar, then its wrapped and indented output.
|
|
14
|
+
* @param run - Block from the shell controller.
|
|
15
|
+
* @param width - Available terminal columns.
|
|
16
|
+
* @returns Rows in display order.
|
|
17
|
+
*/
|
|
18
|
+
export function blockRows(run, width) {
|
|
19
|
+
const reserve = Math.max(stringWidth(MARKER), stringWidth(GUTTER));
|
|
20
|
+
const rows = [{ text: `! ${run.command}`, kind: 'shell', bold: true, highlight: true }];
|
|
21
|
+
const body = [
|
|
22
|
+
...(run.dropped > 0 ? [`… ${run.dropped} earlier lines dropped …`] : []),
|
|
23
|
+
...run.lines,
|
|
24
|
+
];
|
|
25
|
+
if (!body.length)
|
|
26
|
+
body.push(run.status === 'running' ? 'running…' : '(no output)');
|
|
27
|
+
body.forEach((line, index) => {
|
|
28
|
+
const wrapped = plainRows(line, Math.max(8, width - reserve), 'shell');
|
|
29
|
+
wrapped.forEach((row, position) => {
|
|
30
|
+
rows.push({ ...row, text: `${index === 0 && position === 0 ? MARKER : GUTTER}${row.text}` });
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
if (run.status === 'exited' && (run.signal != null || (run.code ?? 0) !== 0)) {
|
|
34
|
+
rows.push({ text: `${GUTTER}exit ${run.signal ?? run.code}`, kind: 'muted' });
|
|
35
|
+
}
|
|
36
|
+
return rows;
|
|
37
|
+
}
|
|
38
|
+
/** Host row index where a block anchored at `anchor` belongs.
|
|
39
|
+
*
|
|
40
|
+
* It goes after every row of the newest message it followed, which is the start of the next message,
|
|
41
|
+
* or the end of the layout when nothing newer is loaded.
|
|
42
|
+
* @param layout - Transcript layout to place into.
|
|
43
|
+
* @param anchor - Durable sequence the command followed.
|
|
44
|
+
* @returns Host row index.
|
|
45
|
+
*/
|
|
46
|
+
function rowAfter(layout, anchor) {
|
|
47
|
+
let low = 0, high = layout.messages.length;
|
|
48
|
+
while (low < high) {
|
|
49
|
+
const mid = (low + high) >> 1;
|
|
50
|
+
if (layout.messages[mid].seq <= anchor)
|
|
51
|
+
low = mid + 1;
|
|
52
|
+
else
|
|
53
|
+
high = mid;
|
|
54
|
+
}
|
|
55
|
+
if (low >= layout.messages.length)
|
|
56
|
+
return layout.length;
|
|
57
|
+
return layout.offsets.get(layout.messages[low].seq) ?? layout.length;
|
|
58
|
+
}
|
|
59
|
+
/** Splice every retained block into a transcript layout.
|
|
60
|
+
*
|
|
61
|
+
* Equal anchors keep creation order, and an anchor older than a block already placed is clamped to
|
|
62
|
+
* it, so the merged stream stays ordered even as history is paged in behind the reader.
|
|
63
|
+
* @param layout - Transcript layout to merge with.
|
|
64
|
+
* @param runs - Blocks from the shell controller, oldest first.
|
|
65
|
+
* @param width - Available terminal columns.
|
|
66
|
+
* @returns The merged row count and a reader over a merged range.
|
|
67
|
+
*/
|
|
68
|
+
export function mergeShellRuns(layout, runs, width) {
|
|
69
|
+
const placements = [];
|
|
70
|
+
let cursor = 0;
|
|
71
|
+
for (const run of runs) {
|
|
72
|
+
const at = Math.max(cursor, Math.min(layout.length, rowAfter(layout, run.anchor)));
|
|
73
|
+
placements.push({ at, rows: blockRows(run, width) });
|
|
74
|
+
cursor = at;
|
|
75
|
+
}
|
|
76
|
+
// Segments alternate host rows and block rows in merged index order.
|
|
77
|
+
const segments = [];
|
|
78
|
+
let merged = 0, host = 0;
|
|
79
|
+
for (const placement of placements) {
|
|
80
|
+
if (placement.at > host) {
|
|
81
|
+
segments.push({ from: merged, count: placement.at - host, host });
|
|
82
|
+
merged += placement.at - host;
|
|
83
|
+
host = placement.at;
|
|
84
|
+
}
|
|
85
|
+
segments.push({ from: merged, count: placement.rows.length, rows: placement.rows });
|
|
86
|
+
merged += placement.rows.length;
|
|
87
|
+
}
|
|
88
|
+
if (host < layout.length)
|
|
89
|
+
segments.push({ from: merged, count: layout.length - host, host });
|
|
90
|
+
const total = merged + Math.max(0, layout.length - host);
|
|
91
|
+
return {
|
|
92
|
+
total,
|
|
93
|
+
viewport(start, end) {
|
|
94
|
+
const rows = [];
|
|
95
|
+
for (const segment of segments) {
|
|
96
|
+
const segmentStart = segment.from, segmentEnd = segment.from + segment.count;
|
|
97
|
+
if (segmentEnd <= start || segmentStart >= end)
|
|
98
|
+
continue;
|
|
99
|
+
const from = Math.max(0, start - segmentStart);
|
|
100
|
+
const to = Math.min(segment.count, end - segmentStart);
|
|
101
|
+
if (to <= from)
|
|
102
|
+
continue;
|
|
103
|
+
if (segment.rows)
|
|
104
|
+
rows.push(...segment.rows.slice(from, to));
|
|
105
|
+
else
|
|
106
|
+
rows.push(...layout.viewport(segment.host + from, segment.host + to));
|
|
107
|
+
}
|
|
108
|
+
return rows;
|
|
109
|
+
},
|
|
110
|
+
};
|
|
111
|
+
}
|
|
@@ -17,6 +17,15 @@ export function classifySubmission(raw, context) {
|
|
|
17
17
|
const value = raw.trim();
|
|
18
18
|
if (!value)
|
|
19
19
|
return { kind: 'ignore' };
|
|
20
|
+
// `!` runs on the machine this client is on; it never reaches the host or the model.
|
|
21
|
+
if (value.startsWith('!')) {
|
|
22
|
+
const command = value.slice(1).trim();
|
|
23
|
+
if (!command)
|
|
24
|
+
return { kind: 'error', message: 'Type a command after !' };
|
|
25
|
+
if (context.screen !== 'chat')
|
|
26
|
+
return { kind: 'error', message: 'Select a session first' };
|
|
27
|
+
return { kind: 'shell', command };
|
|
28
|
+
}
|
|
20
29
|
if (value === '/copy')
|
|
21
30
|
return { kind: 'copy' };
|
|
22
31
|
if (value === '/quit')
|
package/dist/ui/theme/index.d.ts
CHANGED
|
@@ -3,6 +3,11 @@ import type { RowKind } from '../../session/history.ts';
|
|
|
3
3
|
export interface Theme {
|
|
4
4
|
name: string;
|
|
5
5
|
colors: Record<RowKind, string>;
|
|
6
|
+
/** Bar drawn behind a local `!` command line, so it reads as this machine rather than the agent. */
|
|
7
|
+
shell: {
|
|
8
|
+
background: string;
|
|
9
|
+
foreground: string;
|
|
10
|
+
};
|
|
6
11
|
status: Record<'working' | 'ready' | 'offline' | 'model' | 'cost' | 'context' | 'warning' | 'critical' | 'usage', string>;
|
|
7
12
|
accent: string;
|
|
8
13
|
border: string;
|
package/dist/ui/theme/index.js
CHANGED
|
@@ -4,7 +4,8 @@ import { createContext, useContext } from 'react';
|
|
|
4
4
|
export const mocha = {
|
|
5
5
|
name: 'Catppuccin Mocha',
|
|
6
6
|
colors: { user: '#89b4fa', assistant: '#a6e3a1', context: '#f9e2af', text: '#cdd6f4',
|
|
7
|
-
reasoning: '#cba6f7', tool: '#89dceb', success: '#a6e3a1', error: '#f38ba8', muted: '#a6adc8' },
|
|
7
|
+
reasoning: '#cba6f7', tool: '#89dceb', success: '#a6e3a1', error: '#f38ba8', muted: '#a6adc8', shell: '#a6adc8' },
|
|
8
|
+
shell: { background: '#cdd6f4', foreground: '#1e1e2e' },
|
|
8
9
|
status: { working: '#f9e2af', ready: '#a6e3a1', offline: '#f38ba8', model: '#cba6f7',
|
|
9
10
|
cost: '#89dceb', context: '#a6e3a1', warning: '#f9e2af', critical: '#f38ba8', usage: '#a6adc8' },
|
|
10
11
|
accent: '#cba6f7', border: '#6c7086',
|