newmark-agent 0.4.2 → 0.4.4
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/config.example.json +5 -0
- package/dist/conversation-utility-host.bundle.cjs +352 -43
- package/dist/conversation-utility-host.js +3 -0
- package/dist/core/agent.d.ts +52 -4
- package/dist/core/agent.js +274 -27
- package/dist/core/agentKernelRunner.js +34 -17
- package/dist/core/config.d.ts +8 -0
- package/dist/core/conversationKernel.d.ts +24 -0
- package/dist/core/conversationKernel.js +78 -6
- package/dist/core/electronUtilityAgentClient.d.ts +1 -0
- package/dist/core/electronUtilityAgentClient.js +4 -0
- package/dist/core/electronUtilityRuntimePool.d.ts +2 -0
- package/dist/core/electronUtilityRuntimePool.js +11 -0
- package/dist/core/toolPolicy.js +3 -0
- package/dist/core/utilityAgentProtocol.d.ts +7 -0
- package/dist/core/wslAgentClient.d.ts +1 -0
- package/dist/core/wslAgentClient.js +4 -0
- package/dist/core/wslAgentProtocol.d.ts +7 -0
- package/dist/core/wslAgentRuntimePool.d.ts +2 -0
- package/dist/core/wslAgentRuntimePool.js +12 -0
- package/dist/llm/provider.d.ts +13 -1
- package/dist/llm/provider.js +42 -1
- package/dist/main.js +36 -7
- package/dist/providers/provider-adapter.d.ts +3 -1
- package/dist/tools/index.js +4 -2
- package/dist/tui/src/app.js +51 -6
- package/dist/tui/src/data.js +28 -0
- package/dist/tui/src/render.js +121 -60
- package/dist/tui/src/state.js +3 -0
- package/dist/ui/index.html +78 -9
- package/dist/wsl-agent-host.bundle.cjs +352 -43
- package/dist/wsl-agent-host.js +3 -0
- package/package.json +5 -3
|
@@ -52,7 +52,9 @@ export interface NormalizedAgentRequest {
|
|
|
52
52
|
tools: NormalizedTool[];
|
|
53
53
|
temperature: number;
|
|
54
54
|
maxOutputTokens: number;
|
|
55
|
-
|
|
55
|
+
/** dev-0.4.3:模型原生思考强度档位名。Newmark 档位经模型配置
|
|
56
|
+
* `thinking_tier_map` 映射后可能不再是 Newmark 档位名,故为自由字符串。 */
|
|
57
|
+
reasoningEffort?: string;
|
|
56
58
|
apiKey: string;
|
|
57
59
|
baseUrl: string;
|
|
58
60
|
/** 可选的会话标识。仅当目标 provider 显式支持 session_id 语义的上下文缓存
|
package/dist/tools/index.js
CHANGED
|
@@ -350,7 +350,7 @@ class ToolExecutor {
|
|
|
350
350
|
t('subagent_result', 'Return the persisted transcript, mailbox summary, status, and latest result for a peer agent. Target by exact id (preferred) or name.', { id: { type: 'string', description: 'Exact peer id from subagent_list.' }, name: { type: 'string', description: 'Convenience peer name.' } }, []),
|
|
351
351
|
t('subagent_close', 'Close a same-conversation peer. Root can close any peer; a peer can close only itself. Target by exact id (preferred) or name.', { id: { type: 'string', description: 'Exact peer id from subagent_list.' }, name: { type: 'string', description: 'Convenience peer name.' } }, []),
|
|
352
352
|
t('linked_plan', 'Read or update the current conversation linked Markdown plan. Update requires the current expected_revision.', { action: { type: 'string', enum: ['get', 'update'] }, markdown: { type: 'string' }, expected_revision: { type: 'number' } }, ['action']),
|
|
353
|
-
t('build_history_query', 'Read the concrete public work details of one historical Build Block.
|
|
353
|
+
t('build_history_query', 'Read the concrete public work details (tool calls, results, file changes, guides) of one historical Build Block. Call it proactively when the current task continues, fixes, verifies, or depends on earlier work: reuse the returned activity instead of re-investigating from scratch. Do not call it merely to answer completion status already exposed by the prompt. Select by newest-to-oldest history_index, or by run_id returned from an earlier query. Every activity/guide content is bounded to max_chars (default 2000) to keep the read lean and cache-friendly.', { history_index: { type: 'number', minimum: 1, description: '1-based historical Build Block index from the request ledger; 1 is the newest previous task.' }, run_id: { type: 'string', description: 'Exact run id returned by an earlier build_history_query result.' }, max_events: { type: 'number', minimum: 1, maximum: 200, description: 'Maximum trailing public work events; defaults to 80.' }, max_chars: { type: 'number', minimum: 100, maximum: 4000, description: 'Per-event/per-guide content character bound; defaults to 2000.' } }, []),
|
|
354
354
|
t('context_compress', 'Actively compress the LLM context history for this conversation. This collapses older history entries into a concise summary while preserving the recent tail, which reduces context tokens and cost. IMPORTANT: it affects only the LLM context (what the model sees); the displayed conversation history shown to the user is never altered. Call this when the conversation is long, token pressure is high, or you judge that older turns are no longer needed in full. Idempotent and safe: repeated calls produce incremental summaries.', { keep_recent: { type: 'number', minimum: 2, maximum: 60, description: 'Recent message count to keep uncompressed at the tail. Defaults to the configured keep_recent_messages.' }, force: { type: 'boolean', description: 'Compress even if the context is not yet over the automatic threshold. Defaults to false.' } }, []),
|
|
355
355
|
t('context_history_manage', 'Manage the LLM context history for this conversation without affecting the displayed conversation history. This is the active context-management surface. The hot cache stays bounded; evicted folded segments remain in a conversation-isolated append-only cold archive and are loaded only by explicit search/read/restore calls. Actions: list returns a bounded index of current context entries; remove declares one long-term entry for unload (see below); summarize folds a contiguous current range; restore reinserts a folded segment when its summary marker is still present; search finds matching hot or archived segments; read returns one bounded segment without injecting the whole archive; status reports budgets, hot cache, cold archive, the protected recent zone, and pending removals. The recent context tail and last user message are protected from remove/summarize unless dangerous is true. For cache-optimization, remove ONLY targets long-term history (never the protected recent tail or last user message) and does NOT unload immediately: the declared entry stays in context for the rest of the current Build Block so the provider prefix cache stays stable, then is physically removed when the Block ends — applying to subsequent Blocks only.', {
|
|
356
356
|
action: { type: 'string', enum: ['list', 'remove', 'summarize', 'restore', 'search', 'read', 'status'], description: 'list current entries; remove one; summarize a range; restore by restore_id; search hot/cold folded segments; read one bounded folded segment; status report context budgets and storage.' },
|
|
@@ -372,7 +372,9 @@ class ToolExecutor {
|
|
|
372
372
|
t('background_tool', 'Run a tool call in the background WITHOUT blocking the conversation turn. Pass the target tool name and its arguments; this tool returns a background_id IMMEDIATELY, and the real tool keeps running in the background. The result is persisted and can be retrieved later with read_tool_result. Use this for long-running or non-critical tools (bash, web_fetch, long read/grep) so the conversation continues without waiting. The background result stays OUT of context until you explicitly read it, preserving prompt-cache hit rate. Orchestration/flow/subagent/question tools cannot be backgrounded.', { tool: { type: 'string', description: 'The tool name to run in the background (e.g. bash, web_fetch, read, grep).' }, args: { type: 'object', description: 'The arguments object for the target tool, matching its normal schema.' } }, ['tool']),
|
|
373
373
|
t('read_tool_result', 'Read the result of a background tool. Pass the background_id returned by background_tool. When status is running, returns a running marker; when done, returns the persisted result (optionally release it from storage after reading); when error, returns the failure. Background results are released from storage only when you set release=true.', { background_id: { type: 'string', description: 'The background_id returned by background_tool.' }, release: { type: 'boolean', description: 'Set true to release the persisted result from storage after reading it.' } }, ['background_id']),
|
|
374
374
|
t('goal_manage', 'Actively manage the persistent Goal state for this conversation. You may enter Goal mode, update (edit) its objective, mark it complete, or exit Goal mode yourself. Call this when the user asks you to pursue a persistent objective, when the objective changes, when you have verified the objective is genuinely achieved, or when you judge the Goal is no longer needed and should be cleared. This is the agent-side state control that mirrors the GUI goal panel controls. enter/update require objective; complete marks the objective verified and exits Goal mode; exit clears the Goal (and returns to Build mode) without claiming completion.', { action: { type: 'string', enum: ['enter', 'update', 'complete', 'exit'], description: 'enter=enter Goal mode and set the objective; update=edit the objective (records a change); complete=mark the objective verified-achieved and exit Goal mode; exit=clear the Goal and return to Build mode without claiming completion.' }, objective: { type: 'string', description: 'The Goal objective text. Required for enter and update.' }, reason: { type: 'string', description: 'Optional one-line reason for the state change, recorded for audit.' } }, ['action']),
|
|
375
|
-
t('
|
|
375
|
+
t('task_read', 'Read the persistent inline task checklist of the CURRENT conversation (the same list the GUI Task panel and TUI plan view render). Returns bounded items (id, status, task text <=240 chars) plus unfinished count. Read-only and side-effect free; call this whenever you need concrete task-list state instead of assuming it. Kept out of the system prompt so provider prefix caching stays stable.', {}, []),
|
|
376
|
+
t('task_create', 'Maintain the persistent inline task checklist of the CURRENT conversation. This is the durable replacement for ephemeral in-reply checklists: items you create here appear in the GUI Task panel and TUI plan view immediately and persist across Build Blocks. Actions: create appends one task item; update changes status (pending|in_progress|done) or text of one item by id or index; clear removes completed items. Use create when starting a multi-step task, update as each item progresses, and update status=done when verified. Returns a compact confirmation, never the full list.', { action: { type: 'string', enum: ['create', 'update', 'clear'], description: 'create=append a task item; update=change one item status/text; clear=remove completed items.' }, task: { type: 'string', description: 'Task text for create, or new text for update. Short actionable label (<=400 chars).' }, text: { type: 'string', description: 'Alias of task.' }, id: { type: 'string', description: 'Item id from task_read for update.' }, index: { type: 'number', description: '0-based item index for update (alternative to id).' }, status: { type: 'string', enum: ['pending', 'in_progress', 'done', 'blocked'], description: 'New status for update; blocked is stored as pending.' } }, ['action']),
|
|
377
|
+
t('conversation_rename', 'Rename the CURRENT conversation to a concise, descriptive title you choose. Use this when the user asks to rename the conversation or when the current auto-generated title no longer describes the work. Keep the title short (a few words) and cache-friendly: a concrete noun phrase describing the task, never a sentence or quoted prompt.', { title: { type: 'string', description: 'The new conversation title (a short noun phrase, e.g. "Fix TUI color leak", "Add goal_manage tool").' } }, ['title']),
|
|
376
378
|
t('question', 'Ask user a multiple-choice question', { questions: { type: 'array' } }, ['questions']),
|
|
377
379
|
t('skill_download', 'Download a skill', { name: { type: 'string' }, source: { type: 'string' } }, ['name', 'source']),
|
|
378
380
|
t('skill', 'Search enabled skill metadata or load one exact skill body on demand. Use query when unsure, then name to load the selected skill.', { query: { type: 'string', maxLength: 200 }, name: { type: 'string', maxLength: 200 } }, []),
|
package/dist/tui/src/app.js
CHANGED
|
@@ -51,8 +51,10 @@ const ESC = "\u001b[";
|
|
|
51
51
|
function createPaintScheduler(state, output = process.stdout, renderFrame = render) {
|
|
52
52
|
let pending = false;
|
|
53
53
|
let lastFrame = "";
|
|
54
|
+
let cancelled = false;
|
|
54
55
|
const flush = () => {
|
|
55
56
|
pending = false;
|
|
57
|
+
if (cancelled) return;
|
|
56
58
|
const frame = renderFrame(state);
|
|
57
59
|
if (frame === lastFrame) return false;
|
|
58
60
|
lastFrame = frame;
|
|
@@ -65,6 +67,8 @@ function createPaintScheduler(state, output = process.stdout, renderFrame = rend
|
|
|
65
67
|
setImmediate(flush);
|
|
66
68
|
};
|
|
67
69
|
paint.flush = flush;
|
|
70
|
+
// 退出前调用:丢弃任何排队中的重绘,避免恢复主屏后又被画上一帧 TUI 画面。
|
|
71
|
+
paint.cancel = () => { cancelled = true; pending = false; };
|
|
68
72
|
return paint;
|
|
69
73
|
}
|
|
70
74
|
|
|
@@ -108,6 +112,28 @@ function resolveTuiWorkspacePath(args, options = {}) {
|
|
|
108
112
|
return explicitRoot || process.cwd();
|
|
109
113
|
}
|
|
110
114
|
|
|
115
|
+
// 在 Windows 传统控制台(ConHost)下,输出句柄默认不启用
|
|
116
|
+
// ENABLE_VIRTUAL_TERMINAL_PROCESSING,导致备用屏 ?1049h / 清屏 2J 等 ANSI
|
|
117
|
+
// 序列不被解析,主屏保留滚动历史(滚轮能滚回旧帧)。Electron-as-node sidecar
|
|
118
|
+
// 也不会像纯 Node 那样由 libuv 自动启用 VT。这里主动给输出句柄启用 VT 处理,
|
|
119
|
+
// 给输入句柄启用原始输入,作为 main.ts 之外入口的保险。
|
|
120
|
+
function enableWindowsVirtualTerminal() {
|
|
121
|
+
if (process.platform !== 'win32') return;
|
|
122
|
+
try {
|
|
123
|
+
const { spawnSync } = require('node:child_process');
|
|
124
|
+
const script = [
|
|
125
|
+
'Add-Type -TypeDefinition \'using System; using System.Runtime.InteropServices; public static class NewmarkTuiVT { [DllImport("kernel32.dll")] public static extern IntPtr GetStdHandle(int n); [DllImport("kernel32.dll")] public static extern bool GetConsoleMode(IntPtr h, out uint m); [DllImport("kernel32.dll")] public static extern bool SetConsoleMode(IntPtr h, uint m); }\'',
|
|
126
|
+
'$o = [NewmarkTuiVT]::GetStdHandle(-11); $om = [uint32]0; if ([NewmarkTuiVT]::GetConsoleMode($o, [ref]$om)) { [NewmarkTuiVT]::SetConsoleMode($o, ($om -bor 4)) | Out-Null }',
|
|
127
|
+
'$i = [NewmarkTuiVT]::GetStdHandle(-10); $im = [uint32]0; if ([NewmarkTuiVT]::GetConsoleMode($i, [ref]$im)) { [NewmarkTuiVT]::SetConsoleMode($i, (($im -band (-bnot 7)) -bor 512)) | Out-Null }',
|
|
128
|
+
].join('; ');
|
|
129
|
+
spawnSync('powershell.exe', ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command', script], {
|
|
130
|
+
stdio: 'ignore',
|
|
131
|
+
windowsHide: true,
|
|
132
|
+
timeout: 3000,
|
|
133
|
+
});
|
|
134
|
+
} catch {}
|
|
135
|
+
}
|
|
136
|
+
|
|
111
137
|
function start(options = {}) {
|
|
112
138
|
const forcedTerminal = process.env.NEWMARK_FORCE_TTY === "1";
|
|
113
139
|
if ((!process.stdin.isTTY || !process.stdout.isTTY) && !forcedTerminal) {
|
|
@@ -115,6 +141,9 @@ function start(options = {}) {
|
|
|
115
141
|
process.exitCode = 1;
|
|
116
142
|
return;
|
|
117
143
|
}
|
|
144
|
+
// 备用屏依赖输出句柄的 VT 处理;纯 Node 的 libuv 会自动启用,但
|
|
145
|
+
// Electron-as-node sidecar 或强制 TTY 路径不会,这里补上。
|
|
146
|
+
if (forcedTerminal || !process.stdout.isTTY) enableWindowsVirtualTerminal();
|
|
118
147
|
|
|
119
148
|
const args = process.argv.slice(2);
|
|
120
149
|
let adapter;
|
|
@@ -140,24 +169,38 @@ function start(options = {}) {
|
|
|
140
169
|
let timer = null;
|
|
141
170
|
let animationTimer = null;
|
|
142
171
|
let closing = false;
|
|
172
|
+
// 标准 TUI 屏幕模型:进入备用屏幕缓冲(alternate screen buffer)。
|
|
173
|
+
// 备用屏没有主屏的滚动历史:每次刷新用 2J 全量重绘而不是滚动新页面,
|
|
174
|
+
// 鼠标滚轮无法滚回上一次刷新,终端滚动也不会让画面错位(否则高亮行
|
|
175
|
+
// 会随终端滚动"离开屏幕")。
|
|
176
|
+
const enterAltScreen = () => process.stdout.write(`${ESC}?1049h${ESC}?25l${ESC}2J${ESC}H`);
|
|
177
|
+
const leaveAltScreen = () => `${ESC}?25h${ESC}?1049l${ESC}0m${ESC}2J${ESC}H`;
|
|
143
178
|
const paint = createPaintScheduler(state);
|
|
144
179
|
const cleanup = () => {
|
|
145
180
|
if (timer) clearInterval(timer);
|
|
146
181
|
if (animationTimer) clearInterval(animationTimer);
|
|
147
182
|
if (typeof process.stdin.setRawMode === "function") process.stdin.setRawMode(false);
|
|
148
183
|
process.stdin.pause();
|
|
149
|
-
process.stdout.write(`${ESC}?25h${ESC}0m${ESC}2J${ESC}H`);
|
|
150
184
|
};
|
|
151
185
|
const quit = () => {
|
|
152
186
|
if (closing) return;
|
|
153
187
|
closing = true;
|
|
188
|
+
paint.cancel();
|
|
154
189
|
cleanup();
|
|
155
190
|
if (typeof state.adapter.close === "function") state.adapter.close();
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
191
|
+
// 退出序列与关闭消息用 fs.writeSync 同步写入 fd:直接进入内核管道,
|
|
192
|
+
// 不经过异步流缓冲,process.exit() 不会丢弃任何字节(此前异步 write
|
|
193
|
+
// 在退出瞬间被 ConPTY/管道丢弃,导致恢复主屏后看不到退出消息)。
|
|
194
|
+
try {
|
|
195
|
+
const fs = require("node:fs");
|
|
196
|
+
const fd = process.stdout.fd || 1;
|
|
197
|
+
fs.writeSync(fd, `${leaveAltScreen()}\n`);
|
|
198
|
+
fs.writeSync(fd, state.adapterKind === "mock"
|
|
199
|
+
? "Newmark TUI demo closed. No state was saved.\n"
|
|
200
|
+
: "Newmark TUI closed. Conversation and settings state are persisted by Newmark.\n");
|
|
201
|
+
} catch {}
|
|
159
202
|
process.exitCode = 0;
|
|
160
|
-
|
|
203
|
+
process.exit(0);
|
|
161
204
|
};
|
|
162
205
|
|
|
163
206
|
function simulateReply(text) {
|
|
@@ -554,8 +597,10 @@ function start(options = {}) {
|
|
|
554
597
|
process.stdin.resume();
|
|
555
598
|
process.stdin.on("keypress", handleKey);
|
|
556
599
|
process.stdout.on("resize", paint);
|
|
557
|
-
|
|
600
|
+
// 兜底:任何退出路径(包括未捕获异常后的 exit 事件)都恢复主屏缓冲与光标。
|
|
601
|
+
process.on("exit", () => process.stdout.write(`${ESC}?25h${ESC}?1049l${ESC}0m`));
|
|
558
602
|
process.on("SIGTERM", quit);
|
|
603
|
+
enterAltScreen();
|
|
559
604
|
paint();
|
|
560
605
|
}
|
|
561
606
|
|
package/dist/tui/src/data.js
CHANGED
|
@@ -204,6 +204,34 @@ const providers = [
|
|
|
204
204
|
}
|
|
205
205
|
];
|
|
206
206
|
|
|
207
|
+
// Demo-only long-list injection: NEWMARK_TUI_DEMO_MODELS=<count> appends a
|
|
208
|
+
// dedicated stress provider with <count> models so the featureless --demo TUI
|
|
209
|
+
// can exercise long model-selection cursor-follow behavior without any runtime.
|
|
210
|
+
const DEMO_MODEL_COUNT = Number(process.env.NEWMARK_TUI_DEMO_MODELS || 0);
|
|
211
|
+
if (Number.isFinite(DEMO_MODEL_COUNT) && DEMO_MODEL_COUNT > 0) {
|
|
212
|
+
providers.push({
|
|
213
|
+
id: "provider-demo-stress",
|
|
214
|
+
name: "Demo Stress",
|
|
215
|
+
base_url: "https://demo.invalid/v1",
|
|
216
|
+
api_key: "",
|
|
217
|
+
has_api_key: false,
|
|
218
|
+
protocol: "openai",
|
|
219
|
+
enabled: true,
|
|
220
|
+
models: Array.from({ length: DEMO_MODEL_COUNT }, (_, index) => ({
|
|
221
|
+
name: `demo-stress-model-${String(index).padStart(3, "0")}`,
|
|
222
|
+
display: `Stress Model ${String(index).padStart(3, "0")}`,
|
|
223
|
+
description: `Long-list cursor-follow stress model ${index} description`,
|
|
224
|
+
max_tokens: 128000,
|
|
225
|
+
vision: false,
|
|
226
|
+
thinking: false,
|
|
227
|
+
enabled: true,
|
|
228
|
+
speed_rating: "unknown",
|
|
229
|
+
capability_rating: "unknown",
|
|
230
|
+
validation: { status: "unavailable", level: "discovered", checked_at: "" }
|
|
231
|
+
}))
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
|
|
207
235
|
const flows = [
|
|
208
236
|
{
|
|
209
237
|
name: "release-readiness",
|
package/dist/tui/src/render.js
CHANGED
|
@@ -430,19 +430,28 @@ function chatView(state, width, height, p) {
|
|
|
430
430
|
`${p.bold}Conversations${p.reset}`,
|
|
431
431
|
`${p.muted}N new${p.reset}`,
|
|
432
432
|
"",
|
|
433
|
-
...
|
|
434
|
-
const
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
433
|
+
...(() => {
|
|
434
|
+
const conversationRows = state.snapshot.conversations.map((item, i) => {
|
|
435
|
+
const style = state.focusRegion === "content" && i === state.selected && !state.inputMode ? `${p.selected}${p.bold}` : "";
|
|
436
|
+
const running = state.runningConversationKeys?.has(`${state.target.workspaceId}::${item.id}`);
|
|
437
|
+
const marker = running
|
|
438
|
+
? `${p.cyan}${["\\", "—", "/", "—"][state.tick % 4]}${p.reset}`
|
|
439
|
+
: item.title === state.lastConversation
|
|
440
|
+
? `${p.cyan}›${p.reset}`
|
|
441
|
+
: item.pinned
|
|
442
|
+
? `${p.amber}◆${p.reset}`
|
|
443
|
+
: "·";
|
|
444
|
+
const updated = String(item.updatedAt || "").slice(11, 16) || "—";
|
|
445
|
+
return `${style}${marker} ${pad(item.title, 19)} ${p.muted}${updated}${p.reset}`;
|
|
446
|
+
});
|
|
447
|
+
const listViewport = Math.max(1, height - 3);
|
|
448
|
+
const listMaxScroll = Math.max(0, conversationRows.length - listViewport);
|
|
449
|
+
let listScroll = Math.max(0, Math.min(listMaxScroll, Number(state.conversationListScroll) || 0));
|
|
450
|
+
if (state.selected < listScroll) listScroll = state.selected;
|
|
451
|
+
else if (state.selected >= listScroll + listViewport) listScroll = state.selected - listViewport + 1;
|
|
452
|
+
state.conversationListScroll = Math.max(0, Math.min(listMaxScroll, listScroll));
|
|
453
|
+
return conversationRows.slice(state.conversationListScroll, state.conversationListScroll + listViewport);
|
|
454
|
+
})()
|
|
446
455
|
] : [];
|
|
447
456
|
const preview = state.snapshot.conversations[state.selected] || state.snapshot.conversations[0] || {
|
|
448
457
|
id: state.target.conversationId,
|
|
@@ -680,29 +689,34 @@ function modelView(state, width, p) {
|
|
|
680
689
|
const isCurrent = (selection) => selection.kind === current.kind
|
|
681
690
|
&& (selection.kind === "auto"
|
|
682
691
|
|| (selection.providerId === current.providerId && selection.modelId === current.modelId));
|
|
683
|
-
|
|
692
|
+
const rows = [
|
|
684
693
|
...conversationContext(state, p, "Model and reasoning effort"),
|
|
685
|
-
`${p.bold}${tr(state, "Reasoning effort")}${p.reset} ${p.muted}${tr(state, "Shared GUI/TUI request tier · ←/→ changes section")}${p.reset}
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
694
|
+
`${p.bold}${tr(state, "Reasoning effort")}${p.reset} ${p.muted}${tr(state, "Shared GUI/TUI request tier · ←/→ changes section")}${p.reset}`
|
|
695
|
+
];
|
|
696
|
+
const tierStart = rows.length;
|
|
697
|
+
INTELLIGENCE_TIERS.forEach((tier, index) => {
|
|
698
|
+
const style = state.focusRegion === "content" && state.contentColumn === 0 && index === state.selected ? `${p.selected}${p.bold}` : "";
|
|
699
|
+
if (style) state.contentFocusLine = tierStart + index;
|
|
700
|
+
rows.push(`${style} ${tier === currentTier ? `${p.cyan}●${p.reset}` : "○"} ${tier}${p.reset}`);
|
|
701
|
+
});
|
|
702
|
+
rows.push(
|
|
691
703
|
"",
|
|
692
704
|
`${p.bold}${tr(state, "Deployment")}${p.reset} ${p.muted}${tr(state, "Used by this conversation, including its Plan and Subagents")}${p.reset}`,
|
|
693
|
-
""
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
705
|
+
""
|
|
706
|
+
);
|
|
707
|
+
const deploymentStart = rows.length;
|
|
708
|
+
options.forEach((option, index) => {
|
|
709
|
+
const style = state.focusRegion === "content" && state.contentColumn === 1 && index === state.selected ? `${p.selected}${p.bold}` : "";
|
|
710
|
+
if (style) state.contentFocusLine = deploymentStart + index * 2;
|
|
711
|
+
const marker = isCurrent(option.selection) ? `${p.cyan}●${p.reset}` : "○";
|
|
712
|
+
rows.push(`${style} ${marker} ${pad(option.label, Math.max(18, Math.min(32, width - 24)))} ${p.muted}${option.provider}${p.reset}`);
|
|
713
|
+
rows.push(` ${p.muted}${truncate(option.description, Math.max(20, width - 5))}${p.reset}`);
|
|
714
|
+
});
|
|
715
|
+
rows.push(
|
|
703
716
|
"",
|
|
704
717
|
`${p.muted}${tr(state, "Enter applies the focused tier or deployment. Effort persists globally; deployments remain per conversation.")}${p.reset}`
|
|
705
|
-
|
|
718
|
+
);
|
|
719
|
+
return rows;
|
|
706
720
|
}
|
|
707
721
|
|
|
708
722
|
function flowBarView(state, width, p) {
|
|
@@ -736,20 +750,19 @@ function flowListView(state, width, p) {
|
|
|
736
750
|
|
|
737
751
|
function flowTaskView(state, width, p) {
|
|
738
752
|
const components = state.currentFlow?.components || [];
|
|
739
|
-
|
|
753
|
+
const rows = [
|
|
740
754
|
...conversationContext(state, p, "Flow task"),
|
|
741
755
|
`${p.bold}${tr(state, "Flow Task")}${p.reset} ${p.muted}${state.currentFlow?.name || "No workflow"}${p.reset}`,
|
|
742
|
-
""
|
|
743
|
-
...components.flatMap((component, index) => {
|
|
744
|
-
const style = state.focusRegion === "content" && index === state.selected ? `${p.selected}${p.bold}` : "";
|
|
745
|
-
if (style) state.contentFocusLine = 6 + index * 2;
|
|
746
|
-
const mode = component.type === "logic" ? "LOGIC" : String(component.mode || "BUILD").toUpperCase();
|
|
747
|
-
return [
|
|
748
|
-
`${style} ${String(component.id).padStart(2)} ${pad(mode, 7)} ${truncate(component.prompt, Math.max(18, width - 16))}`,
|
|
749
|
-
component.type === "logic" ? ` ${p.muted}true → ${component.goto_true} · false → ${component.goto_false}${p.reset}` : ""
|
|
750
|
-
].filter(Boolean);
|
|
751
|
-
})
|
|
756
|
+
""
|
|
752
757
|
];
|
|
758
|
+
components.forEach((component, index) => {
|
|
759
|
+
const style = state.focusRegion === "content" && index === state.selected ? `${p.selected}${p.bold}` : "";
|
|
760
|
+
if (style) state.contentFocusLine = rows.length;
|
|
761
|
+
const mode = component.type === "logic" ? "LOGIC" : String(component.mode || "BUILD").toUpperCase();
|
|
762
|
+
rows.push(`${style} ${String(component.id).padStart(2)} ${pad(mode, 7)} ${truncate(component.prompt, Math.max(18, width - 16))}`);
|
|
763
|
+
if (component.type === "logic") rows.push(` ${p.muted}true → ${component.goto_true} · false → ${component.goto_false}${p.reset}`);
|
|
764
|
+
});
|
|
765
|
+
return rows;
|
|
753
766
|
}
|
|
754
767
|
|
|
755
768
|
function toolsView(state, width, p) {
|
|
@@ -1143,11 +1156,19 @@ function overlayLines(state, width, p) {
|
|
|
1143
1156
|
}
|
|
1144
1157
|
if (state.overlay === "palette") {
|
|
1145
1158
|
const commands = filteredCommands(state);
|
|
1159
|
+
const paletteViewport = Math.min(7, Math.max(1, commands.length));
|
|
1160
|
+
const paletteMaxScroll = Math.max(0, commands.length - paletteViewport);
|
|
1161
|
+
let paletteScroll = Math.max(0, Math.min(paletteMaxScroll, Number(state.paletteScroll) || 0));
|
|
1162
|
+
if (state.paletteIndex < paletteScroll) paletteScroll = state.paletteIndex;
|
|
1163
|
+
else if (state.paletteIndex >= paletteScroll + paletteViewport) paletteScroll = state.paletteIndex - paletteViewport + 1;
|
|
1164
|
+
state.paletteScroll = Math.max(0, Math.min(paletteMaxScroll, paletteScroll));
|
|
1165
|
+
const visibleCommands = commands.slice(state.paletteScroll, state.paletteScroll + paletteViewport);
|
|
1146
1166
|
const rows = [
|
|
1147
1167
|
`${p.bold}>${p.reset} ${state.paletteQuery}${p.cyan}▏${p.reset}`,
|
|
1148
1168
|
`${p.muted}${"─".repeat(Math.max(8, Math.min(64, width - 10)))}${p.reset}`,
|
|
1149
|
-
...(
|
|
1150
|
-
const
|
|
1169
|
+
...(visibleCommands.length ? visibleCommands.map((command, i) => {
|
|
1170
|
+
const commandIndex = state.paletteScroll + i;
|
|
1171
|
+
const style = commandIndex === state.paletteIndex ? `${p.selected}${p.bold}` : "";
|
|
1151
1172
|
return `${style} ${pad(command.label, Math.max(18, Math.min(52, width - 18)))} ${p.muted}${command.hint}${p.reset}`;
|
|
1152
1173
|
}) : [`${p.muted} No matching commands${p.reset}`]),
|
|
1153
1174
|
"",
|
|
@@ -1156,14 +1177,22 @@ function overlayLines(state, width, p) {
|
|
|
1156
1177
|
return card(rows, Math.min(72, width - 4), p, "Command palette");
|
|
1157
1178
|
}
|
|
1158
1179
|
if (state.overlay === "flow-select") {
|
|
1180
|
+
const flowViewport = Math.min(7, Math.max(1, state.flows.length));
|
|
1181
|
+
const flowMaxScroll = Math.max(0, state.flows.length - flowViewport);
|
|
1182
|
+
let flowScroll = Math.max(0, Math.min(flowMaxScroll, Number(state.flowSelectionScroll) || 0));
|
|
1183
|
+
if (state.flowSelectionIndex < flowScroll) flowScroll = state.flowSelectionIndex;
|
|
1184
|
+
else if (state.flowSelectionIndex >= flowScroll + flowViewport) flowScroll = state.flowSelectionIndex - flowViewport + 1;
|
|
1185
|
+
state.flowSelectionScroll = Math.max(0, Math.min(flowMaxScroll, flowScroll));
|
|
1186
|
+
const visibleFlows = state.flows.slice(state.flowSelectionScroll, state.flowSelectionScroll + flowViewport);
|
|
1159
1187
|
return card([
|
|
1160
1188
|
`${p.bold}Flow mode requires a workflow${p.reset}`,
|
|
1161
1189
|
`${p.muted}This selection is bound to ${state.lastConversation}.${p.reset}`,
|
|
1162
1190
|
"",
|
|
1163
|
-
...(
|
|
1164
|
-
?
|
|
1165
|
-
const
|
|
1166
|
-
|
|
1191
|
+
...(visibleFlows.length
|
|
1192
|
+
? visibleFlows.map((name, i) => {
|
|
1193
|
+
const flowIndex = state.flowSelectionScroll + i;
|
|
1194
|
+
const style = flowIndex === state.flowSelectionIndex ? `${p.selected}${p.bold}` : "";
|
|
1195
|
+
return `${style} ${flowIndex === state.flowSelectionIndex ? "›" : " "} ${name}${p.reset}`;
|
|
1167
1196
|
})
|
|
1168
1197
|
: [`${p.amber}No workflows are configured in ~/.Newmark/Flow.${p.reset}`]),
|
|
1169
1198
|
"",
|
|
@@ -1226,14 +1255,38 @@ function overlayLines(state, width, p) {
|
|
|
1226
1255
|
return [];
|
|
1227
1256
|
}
|
|
1228
1257
|
|
|
1229
|
-
|
|
1258
|
+
// 实时读取终端窗口尺寸:优先 getWindowSize()(每次调用都查询底层 TTY 尺寸,
|
|
1259
|
+
// 不依赖 Node 缓存的 columns/rows,避免 resize 事件丢失或延迟时按旧尺寸绘制),
|
|
1260
|
+
// 其次回退到缓存 columns/rows,再回退 COLUMNS/LINES 环境变量与默认值。
|
|
1261
|
+
function readWindowSize(fallbackColumns = 100, fallbackRows = 30) {
|
|
1262
|
+
const stdout = process.stdout;
|
|
1263
|
+
if (stdout && typeof stdout.getWindowSize === "function") {
|
|
1264
|
+
try {
|
|
1265
|
+
const [width, height] = stdout.getWindowSize();
|
|
1266
|
+
if (Number.isFinite(width) && Number.isFinite(height) && width > 0 && height > 0) {
|
|
1267
|
+
return { columns: Math.max(1, Math.floor(width)), rows: Math.max(1, Math.floor(height)) };
|
|
1268
|
+
}
|
|
1269
|
+
} catch {}
|
|
1270
|
+
}
|
|
1271
|
+
const columns = Number(stdout?.columns) || Number(process.env.COLUMNS) || fallbackColumns;
|
|
1272
|
+
const rows = Number(stdout?.rows) || Number(process.env.LINES) || fallbackRows;
|
|
1273
|
+
return { columns: Math.max(1, Math.floor(columns)), rows: Math.max(1, Math.floor(rows)) };
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1276
|
+
function render(state, columns, rows) {
|
|
1277
|
+
const size = (columns !== undefined && rows !== undefined)
|
|
1278
|
+
? { columns: Math.max(1, Math.floor(Number(columns) || 1)), rows: Math.max(1, Math.floor(Number(rows) || 1)) }
|
|
1279
|
+
: readWindowSize();
|
|
1230
1280
|
const p = palette(state);
|
|
1231
|
-
|
|
1232
|
-
|
|
1281
|
+
// 严格约束:直接采用终端实际尺寸,绝不通过 Math.max(52/20) 强制放大。
|
|
1282
|
+
// 终端窗口小于内部最小布局时,内容在输出阶段裁剪到窗口内,否则帧会
|
|
1283
|
+
// 画到窗口之外(用户看不到底部行/右侧列,如小窗口下的模型长菜单)。
|
|
1284
|
+
const width = size.columns;
|
|
1285
|
+
const height = size.rows;
|
|
1233
1286
|
const compact = width < 78;
|
|
1234
1287
|
const sidebarWidth = compact ? 0 : 22;
|
|
1235
1288
|
const contentWidth = width - sidebarWidth - 2;
|
|
1236
|
-
const bodyHeight = height - 3;
|
|
1289
|
+
const bodyHeight = Math.max(1, height - 3);
|
|
1237
1290
|
const sidebar = sidebarWidth ? renderSidebar(state, bodyHeight, sidebarWidth, p) : [];
|
|
1238
1291
|
const content = renderContent(state, contentWidth, bodyHeight, p);
|
|
1239
1292
|
let contentLines = content;
|
|
@@ -1242,8 +1295,11 @@ function render(state, columns = process.stdout.columns || 100, rows = process.s
|
|
|
1242
1295
|
let scroll = Math.max(0, Math.min(maximumScroll, Number(state.contentScroll) || 0));
|
|
1243
1296
|
const focusLine = Number(state.contentFocusLine) || -1;
|
|
1244
1297
|
if (focusLine >= 0) {
|
|
1245
|
-
|
|
1246
|
-
|
|
1298
|
+
// 居中跟随:焦点行尽量保持在视口中上部,上下都留出内容,避免选中行
|
|
1299
|
+
// 贴住视口边缘或被裁掉其相邻行(如两行一组的模型选项主行+描述行)。
|
|
1300
|
+
const targetLine = Math.min(Math.max(0, Math.floor(bodyHeight / 3)), Math.max(0, bodyHeight - 2));
|
|
1301
|
+
if (focusLine < scroll + targetLine) scroll = Math.max(0, focusLine - targetLine);
|
|
1302
|
+
else if (focusLine >= scroll + bodyHeight - 1 - targetLine) scroll = Math.min(maximumScroll, focusLine - (bodyHeight - 1 - targetLine));
|
|
1247
1303
|
}
|
|
1248
1304
|
scroll = Math.max(0, Math.min(maximumScroll, scroll));
|
|
1249
1305
|
state.contentScroll = scroll;
|
|
@@ -1262,20 +1318,25 @@ function render(state, columns = process.stdout.columns || 100, rows = process.s
|
|
|
1262
1318
|
: "";
|
|
1263
1319
|
if (compact) body.unshift(pad(top, width));
|
|
1264
1320
|
const focus = state.focusRegion === "menu" ? "MENU" : "CONTENT";
|
|
1265
|
-
const footer = `${p.panel} ${p.cyan}${focus}${p.reset}${p.panel}${p.muted} · ${truncate(state.notice, width - 42)}${p.reset}${p.panel}${" ".repeat(Math.max(1, width - visibleLength(state.notice) - visibleLength(focus) - 35))}Tab back ? help Q quit ${p.reset}`;
|
|
1266
|
-
const
|
|
1267
|
-
|
|
1268
|
-
|
|
1321
|
+
const footer = `${p.panel} ${p.cyan}${focus}${p.reset}${p.panel}${p.muted} · ${truncate(state.notice, Math.max(1, width - 42))}${p.reset}${p.panel}${" ".repeat(Math.max(1, width - visibleLength(state.notice) - visibleLength(focus) - 35))}Tab back ? help Q quit ${p.reset}`;
|
|
1322
|
+
const bodyLines = Math.max(1, height - 1);
|
|
1323
|
+
const renderedBody = body.slice(0, bodyLines);
|
|
1324
|
+
while (renderedBody.length < bodyLines) renderedBody.push(pad("", width));
|
|
1325
|
+
// 输出阶段严格边界:每行按可见宽度截断到窗口宽度,行数限制在窗口高度内,
|
|
1326
|
+
// 确保无论内部最小布局如何,帧绝不会画到窗口之外。
|
|
1327
|
+
const boundedBody = renderedBody.map((line) => truncate(line, width));
|
|
1328
|
+
const boundedFooter = truncate(pad(footer, width), width);
|
|
1329
|
+
let output = `${ESC}?25l${ESC}2J${ESC}H${p.paint}${boundedBody.join("\n")}\n${boundedFooter}`;
|
|
1269
1330
|
const overlay = overlayLines(state, width, p);
|
|
1270
1331
|
if (overlay.length) {
|
|
1271
1332
|
const overlayWidth = Math.max(...overlay.map(visibleLength));
|
|
1272
1333
|
const x = Math.max(1, Math.floor((width - overlayWidth) / 2) + 1);
|
|
1273
1334
|
const y = Math.max(2, Math.floor((height - overlay.length) / 2));
|
|
1274
1335
|
overlay.forEach((line, index) => {
|
|
1275
|
-
output += `${ESC}${y + index};${x}H${line}`;
|
|
1336
|
+
output += `${ESC}${y + index};${x}H${truncate(line, width)}`;
|
|
1276
1337
|
});
|
|
1277
1338
|
}
|
|
1278
1339
|
return `${output}${p.final}`;
|
|
1279
1340
|
}
|
|
1280
1341
|
|
|
1281
|
-
module.exports = { render, stripAnsi, visibleLength, wrapText };
|
|
1342
|
+
module.exports = { render, readWindowSize, stripAnsi, visibleLength, wrapText };
|
package/dist/tui/src/state.js
CHANGED
|
@@ -183,6 +183,8 @@ function createState(options = {}) {
|
|
|
183
183
|
workflowDraft: { name: "", mode: "build", prompt: "" },
|
|
184
184
|
workflowFormIndex: 0,
|
|
185
185
|
flowSelectionIndex: 0,
|
|
186
|
+
flowSelectionScroll: 0,
|
|
187
|
+
conversationListScroll: 0,
|
|
186
188
|
flowByConversation: initialFlow ? { [`${target.workspaceId}::${target.conversationId}`]: initialFlow } : {},
|
|
187
189
|
currentFlow: initialFlow,
|
|
188
190
|
theme: appearance.theme,
|
|
@@ -192,6 +194,7 @@ function createState(options = {}) {
|
|
|
192
194
|
settingChoiceIndex: 0,
|
|
193
195
|
paletteQuery: "",
|
|
194
196
|
paletteIndex: 0,
|
|
197
|
+
paletteScroll: 0,
|
|
195
198
|
inputMode: false,
|
|
196
199
|
input: "",
|
|
197
200
|
inputCursor: 0,
|