@semalt-ai/code 1.8.1 → 1.8.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/.claude/settings.local.json +14 -1
- package/CLAUDE.md +2 -1
- package/index.js +29 -8
- package/lib/agent.js +725 -133
- package/lib/api.js +193 -59
- package/lib/commands.js +263 -201
- package/lib/config.js +33 -4
- package/lib/constants.js +52 -2
- package/lib/metrics.js +16 -3
- package/lib/permissions.js +73 -73
- package/lib/prompts.js +90 -86
- package/lib/tool_specs.js +499 -0
- package/lib/tools.js +418 -198
- package/lib/ui/ansi.js +13 -1
- package/lib/ui/chat-history.js +212 -61
- package/lib/ui/create-ui.js +145 -377
- package/lib/ui/diff.js +91 -78
- package/lib/ui/format.js +247 -0
- package/lib/ui/input-field.js +200 -107
- package/lib/ui/layout.js +0 -2
- package/lib/ui/messages.js +44 -0
- package/lib/ui/select.js +114 -0
- package/lib/ui/status-bar.js +179 -42
- package/lib/ui/stream.js +8 -12
- package/lib/ui/terminal.js +60 -0
- package/lib/ui/theme.js +99 -0
- package/lib/ui/utils.js +135 -6
- package/lib/ui/writer.js +603 -0
- package/lib/ui.js +11 -6
- package/package.json +1 -1
- package/lib/ui/legacy.js +0 -130
package/lib/config.js
CHANGED
|
@@ -2,9 +2,31 @@
|
|
|
2
2
|
|
|
3
3
|
const fs = require('fs');
|
|
4
4
|
const path = require('path');
|
|
5
|
+
const { URL } = require('url');
|
|
5
6
|
|
|
6
7
|
const { CONFIG_PATH, DEFAULT_CONFIG } = require('./constants');
|
|
7
8
|
|
|
9
|
+
let _apiKeyAnyWarned = false;
|
|
10
|
+
const _LOCAL_HOSTS = new Set(['127.0.0.1', 'localhost', '[::1]', '::1']);
|
|
11
|
+
|
|
12
|
+
function _maybeWarnApiKeyAny(cfg) {
|
|
13
|
+
if (_apiKeyAnyWarned) return;
|
|
14
|
+
if (cfg.api_key !== 'any') return;
|
|
15
|
+
let host = '';
|
|
16
|
+
try {
|
|
17
|
+
host = new URL(cfg.api_base).hostname;
|
|
18
|
+
} catch {
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
if (_LOCAL_HOSTS.has(host)) return;
|
|
22
|
+
_apiKeyAnyWarned = true;
|
|
23
|
+
// audit: allowed — pre-UI startup warning, fires once before TUI initialises.
|
|
24
|
+
process.stderr.write(
|
|
25
|
+
"⚠ api_key='any' against non-local endpoint — requests will likely fail " +
|
|
26
|
+
"with 401. Run 'semalt-code config set api_key <key>' to set a real key.\n"
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
|
|
8
30
|
function normalizeConfig(cfg = {}) {
|
|
9
31
|
const merged = { ...DEFAULT_CONFIG, ...cfg };
|
|
10
32
|
// Ensure every DEFAULT_CONFIG key is present without overwriting existing values
|
|
@@ -33,6 +55,7 @@ function normalizeConfig(cfg = {}) {
|
|
|
33
55
|
merged.dashboard_model_id = Number.isInteger(cfg.dashboard_model_id) && cfg.dashboard_model_id > 0
|
|
34
56
|
? cfg.dashboard_model_id
|
|
35
57
|
: null;
|
|
58
|
+
merged.repair_malformed_tool_xml = cfg.repair_malformed_tool_xml === true;
|
|
36
59
|
merged.models = Array.isArray(cfg.models)
|
|
37
60
|
? cfg.models
|
|
38
61
|
.filter((entry) => entry &&
|
|
@@ -53,6 +76,9 @@ function normalizeConfig(cfg = {}) {
|
|
|
53
76
|
if (Number.isInteger(entry.context_length) && entry.context_length > 0) {
|
|
54
77
|
normalized.context_length = entry.context_length;
|
|
55
78
|
}
|
|
79
|
+
// native_tools defaults to true; only explicit false/0/"false"/"0" opts out.
|
|
80
|
+
const nt = entry.native_tools;
|
|
81
|
+
normalized.native_tools = !(nt === false || nt === 0 || nt === '0' || nt === 'false');
|
|
56
82
|
return normalized;
|
|
57
83
|
})
|
|
58
84
|
: [];
|
|
@@ -61,13 +87,16 @@ function normalizeConfig(cfg = {}) {
|
|
|
61
87
|
|
|
62
88
|
function loadConfig() {
|
|
63
89
|
fs.mkdirSync(path.dirname(CONFIG_PATH), { recursive: true });
|
|
90
|
+
let cfg;
|
|
64
91
|
if (fs.existsSync(CONFIG_PATH)) {
|
|
65
92
|
try {
|
|
66
93
|
const data = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8'));
|
|
67
|
-
|
|
94
|
+
cfg = normalizeConfig(data);
|
|
68
95
|
} catch {}
|
|
69
96
|
}
|
|
70
|
-
|
|
97
|
+
if (!cfg) cfg = normalizeConfig();
|
|
98
|
+
_maybeWarnApiKeyAny(cfg);
|
|
99
|
+
return cfg;
|
|
71
100
|
}
|
|
72
101
|
|
|
73
102
|
function saveConfig(cfg) {
|
|
@@ -94,8 +123,8 @@ function configShow(systemPromptOverride = null) {
|
|
|
94
123
|
if (systemPromptOverride) {
|
|
95
124
|
lines.push(` system_prompt: [override from ${systemPromptOverride}]`);
|
|
96
125
|
} else {
|
|
97
|
-
const {
|
|
98
|
-
lines.push(` system_prompt: ${
|
|
126
|
+
const { getSystemPrompt } = require('./prompts');
|
|
127
|
+
lines.push(` system_prompt: ${getSystemPrompt().slice(0, 80)}...`);
|
|
99
128
|
}
|
|
100
129
|
return lines.join('\n');
|
|
101
130
|
}
|
package/lib/constants.js
CHANGED
|
@@ -12,23 +12,39 @@ const DEFAULT_CONFIG = {
|
|
|
12
12
|
api_key: 'any',
|
|
13
13
|
dashboard_url: 'https://cli.semalt.ai',
|
|
14
14
|
auth_token: '',
|
|
15
|
-
default_model: '
|
|
15
|
+
default_model: '',
|
|
16
16
|
dashboard_model_id: null,
|
|
17
17
|
temperature: 0.7,
|
|
18
18
|
request_timeout_ms: DEFAULT_API_TIMEOUT_MS,
|
|
19
19
|
stream: true,
|
|
20
|
+
// native_tools (boolean, default true): when true, the
|
|
21
|
+
// client sends an OpenAI-format `tools` parameter and
|
|
22
|
+
// expects structured tool_calls in responses. Set to
|
|
23
|
+
// false only for models/endpoints that do not support
|
|
24
|
+
// native function calling (legacy finetunes, XML-only
|
|
25
|
+
// adapters). Per-profile flag on models[] entries.
|
|
20
26
|
models: [],
|
|
21
27
|
theme: 'dark',
|
|
22
28
|
max_file_size_kb: 512,
|
|
23
29
|
command_timeout_ms: 30000,
|
|
24
30
|
max_output_lines: 50,
|
|
31
|
+
http_fetch_max_bytes: 262144,
|
|
25
32
|
show_token_count: true,
|
|
26
33
|
show_cost: false,
|
|
27
34
|
system_prompt_mode: 'system_role',
|
|
35
|
+
repair_malformed_tool_xml: false,
|
|
28
36
|
};
|
|
29
37
|
|
|
30
38
|
const CONFIG_PATH = path.join(os.homedir(), '.semalt-ai', 'config.json');
|
|
31
39
|
|
|
40
|
+
// TAG_REGISTRY classifies every XML tag the stream parser may encounter.
|
|
41
|
+
// For 'tool'-type tags, the *parameter schema* lives in lib/tool_specs.js
|
|
42
|
+
// (TOOL_SPECS) — that file is the single source of truth for argument
|
|
43
|
+
// names, types, required flags, and descriptions used to build the
|
|
44
|
+
// native function-calling `tools` array and the system-prompt tag
|
|
45
|
+
// inventory. Adding or renaming a 'tool' entry here requires a matching
|
|
46
|
+
// change in TOOL_SPECS; the assertion at the bottom of this module
|
|
47
|
+
// enforces that parity at load time.
|
|
32
48
|
const TAG_REGISTRY = {
|
|
33
49
|
// Rendered visually in chat, never shown as raw text
|
|
34
50
|
think: { type: 'visual', streaming: true, display: 'think_bubble' },
|
|
@@ -59,7 +75,6 @@ const TAG_REGISTRY = {
|
|
|
59
75
|
download: { type: 'tool', streaming: false, label: 'Downloading' },
|
|
60
76
|
upload: { type: 'tool', streaming: false, label: 'Uploading' },
|
|
61
77
|
http_get: { type: 'tool', streaming: false, label: 'Fetching URL' },
|
|
62
|
-
http_get_next: { type: 'tool', streaming: false, label: 'Fetching next content chunk' },
|
|
63
78
|
ask_user: { type: 'tool', streaming: false, label: 'Asking user' },
|
|
64
79
|
store_memory: { type: 'tool', streaming: false, label: 'Storing memory' },
|
|
65
80
|
recall_memory: { type: 'tool', streaming: false, label: 'Recalling memory' },
|
|
@@ -81,6 +96,14 @@ const TAG_REGISTRY = {
|
|
|
81
96
|
tool_call: { type: 'tool', streaming: false, label: 'Using tool' },
|
|
82
97
|
function_call: { type: 'tool', streaming: false, label: 'Using tool' },
|
|
83
98
|
|
|
99
|
+
// Qwen3-Coder / Qwen3.5 XML tool-call format: `<function=tool_name>…</function>`.
|
|
100
|
+
// The tool name is carried as an `=name` suffix on the opening tag rather
|
|
101
|
+
// than an attribute; `parameter` (already registered as `strip` above) covers
|
|
102
|
+
// the matching `<parameter=key>…</parameter>` child tags. StreamParser splits
|
|
103
|
+
// the tag name on `[\s=]`, so the registry lookup for `<function=read_file>`
|
|
104
|
+
// resolves here.
|
|
105
|
+
function: { type: 'tool', streaming: false, label: 'Using tool' },
|
|
106
|
+
|
|
84
107
|
// Silently stripped — model wrapper artifacts
|
|
85
108
|
answer: { type: 'strip' },
|
|
86
109
|
response: { type: 'strip' },
|
|
@@ -90,8 +113,35 @@ const TAG_REGISTRY = {
|
|
|
90
113
|
text: { type: 'strip' },
|
|
91
114
|
result: { type: 'strip' },
|
|
92
115
|
code: { type: 'strip' },
|
|
116
|
+
|
|
117
|
+
// Protocol wrapper: the model's declared final reply to the user. Tags are
|
|
118
|
+
// stripped from rendered output but the inner content IS the user-facing
|
|
119
|
+
// answer and must stream through onToken, not be buffered like tool blocks.
|
|
120
|
+
final_answer: { type: 'final', streaming: true, label: 'Final answer' },
|
|
93
121
|
};
|
|
94
122
|
|
|
123
|
+
// Load-time parity check: every 'tool'-type tag in TAG_REGISTRY must have a
|
|
124
|
+
// matching entry in TOOL_SPECS, and TOOL_SPECS must not declare phantom
|
|
125
|
+
// tools that aren't registered. Requiring tool_specs.js here (rather than
|
|
126
|
+
// at the top of the file) keeps the module boundary one-directional —
|
|
127
|
+
// tool_specs.js does not depend on this file.
|
|
128
|
+
const { TOOL_SPECS } = require('./tool_specs');
|
|
129
|
+
(function assertToolSpecParity() {
|
|
130
|
+
const registryTools = Object.entries(TAG_REGISTRY)
|
|
131
|
+
.filter(([, v]) => v.type === 'tool')
|
|
132
|
+
.map(([k]) => k)
|
|
133
|
+
.sort();
|
|
134
|
+
const specTools = Object.keys(TOOL_SPECS).sort();
|
|
135
|
+
const missing = registryTools.filter((k) => !Object.prototype.hasOwnProperty.call(TOOL_SPECS, k));
|
|
136
|
+
const extra = specTools.filter((k) => !(k in TAG_REGISTRY) || TAG_REGISTRY[k].type !== 'tool');
|
|
137
|
+
if (missing.length || extra.length) {
|
|
138
|
+
const parts = [];
|
|
139
|
+
if (missing.length) parts.push(`missing in TOOL_SPECS: ${missing.join(', ')}`);
|
|
140
|
+
if (extra.length) parts.push(`extra in TOOL_SPECS: ${extra.join(', ')}`);
|
|
141
|
+
throw new Error(`TAG_REGISTRY ↔ TOOL_SPECS mismatch — ${parts.join('; ')}`);
|
|
142
|
+
}
|
|
143
|
+
})();
|
|
144
|
+
|
|
95
145
|
module.exports = {
|
|
96
146
|
CONFIG_PATH,
|
|
97
147
|
DEFAULT_API_TIMEOUT_MS,
|
package/lib/metrics.js
CHANGED
|
@@ -32,13 +32,22 @@ class Metrics {
|
|
|
32
32
|
}
|
|
33
33
|
|
|
34
34
|
tokenLimitStatus() {
|
|
35
|
-
if (this.modelTokenLimit === null) return null;
|
|
36
35
|
const used = this.contextTokens();
|
|
36
|
+
if (this.modelTokenLimit == null) {
|
|
37
|
+
// No known limit — still expose `used` once we have a turn's prompt_tokens
|
|
38
|
+
// so the UI can render "N tok · limit unknown" instead of hiding the line.
|
|
39
|
+
if (!this.turns.length || !used) return null;
|
|
40
|
+
return { used, limit: null, pct: null, bar: null };
|
|
41
|
+
}
|
|
37
42
|
const pct = Math.round((used / this.modelTokenLimit) * 100);
|
|
38
43
|
const bar = this._buildBar(pct, 10);
|
|
39
44
|
return { used, limit: this.modelTokenLimit, pct, bar };
|
|
40
45
|
}
|
|
41
46
|
|
|
47
|
+
setModelTokenLimit(limit) {
|
|
48
|
+
this.modelTokenLimit = Number.isInteger(limit) && limit > 0 ? limit : null;
|
|
49
|
+
}
|
|
50
|
+
|
|
42
51
|
_buildBar(pct, width) {
|
|
43
52
|
const filled = Math.min(Math.round((pct / 100) * width), width);
|
|
44
53
|
const empty = Math.max(0, width - filled);
|
|
@@ -79,8 +88,12 @@ class Metrics {
|
|
|
79
88
|
|
|
80
89
|
const status = this.tokenLimitStatus();
|
|
81
90
|
if (status !== null) {
|
|
82
|
-
|
|
83
|
-
|
|
91
|
+
if (status.limit === null) {
|
|
92
|
+
lines.push(row(` Context used: ${status.used} (limit unknown)`));
|
|
93
|
+
} else {
|
|
94
|
+
lines.push(row(` Context used: ${this.contextTokens()}`));
|
|
95
|
+
lines.push(row(` Token limit: ${status.used}/${status.limit} (${status.pct}%)`));
|
|
96
|
+
}
|
|
84
97
|
}
|
|
85
98
|
|
|
86
99
|
lines.push(row(` Duration: ${durationStr}`));
|
package/lib/permissions.js
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
+
const writer = require('./ui/writer');
|
|
4
|
+
const messages = require('./ui/messages');
|
|
5
|
+
|
|
3
6
|
const TIER_FS = ['read_file', 'write_file', 'append_file', 'delete_file', 'list_dir', 'make_dir', 'move_file', 'copy_file', 'file_stat', 'search_files', 'store_memory', 'recall_memory'];
|
|
4
7
|
const TIER_EXEC = ['exec'];
|
|
5
8
|
const TIER_NET = ['http_get', 'download'];
|
|
@@ -8,7 +11,6 @@ const TIER_SYS = ['system_info', 'get_env', 'set_env'];
|
|
|
8
11
|
const TIER_MAP = { fs: TIER_FS, exec: TIER_EXEC, net: TIER_NET, sys: TIER_SYS };
|
|
9
12
|
const READONLY_BLOCKED = new Set(['write_file', 'append_file', 'delete_file', 'move_file', 'copy_file']);
|
|
10
13
|
|
|
11
|
-
let _permissionCounter = 0;
|
|
12
14
|
let _permissionQueueTail = Promise.resolve();
|
|
13
15
|
|
|
14
16
|
function createPermissionManager(ui, { allowedTiers = [], readonly = false } = {}) {
|
|
@@ -21,8 +23,8 @@ function createPermissionManager(ui, { allowedTiers = [], readonly = false } = {
|
|
|
21
23
|
}
|
|
22
24
|
|
|
23
25
|
const state = {
|
|
24
|
-
|
|
25
|
-
|
|
26
|
+
autoApproveAll: false,
|
|
27
|
+
sessionApprovedTags: new Set(),
|
|
26
28
|
};
|
|
27
29
|
|
|
28
30
|
let uiCallbacks = null;
|
|
@@ -40,127 +42,127 @@ function createPermissionManager(ui, { allowedTiers = [], readonly = false } = {
|
|
|
40
42
|
|
|
41
43
|
const MAX_DESC_LINES = 12;
|
|
42
44
|
|
|
43
|
-
|
|
45
|
+
// The picker renders into the writer's modal region — a live band above
|
|
46
|
+
// the status bar that redraws in place on every keystroke. Arrow-key
|
|
47
|
+
// navigation rebuilds the lines array and calls onShowModal again; nothing
|
|
48
|
+
// lands in scrollback until the user confirms. On resolve/cancel the
|
|
49
|
+
// modal is cleared and a single summary line is emitted to scrollback
|
|
50
|
+
// (for multi-line descriptions — e.g. a file-write diff — the full body
|
|
51
|
+
// is retained so the user can still see what was approved).
|
|
52
|
+
function requestPermission(description, onShowModal, onCloseModal, onCaptureNavigation) {
|
|
44
53
|
// Serialize dialogs: each permission waits for the previous one to be answered
|
|
45
54
|
const myTurn = _permissionQueueTail;
|
|
46
55
|
let releaseQueue;
|
|
47
56
|
_permissionQueueTail = new Promise((r) => { releaseQueue = r; });
|
|
48
57
|
|
|
49
58
|
return myTurn.then(() => new Promise((resolve) => {
|
|
50
|
-
const uniqueId = `perm_${++_permissionCounter}`;
|
|
51
59
|
const options = ['Yes', 'Always', 'No'];
|
|
52
60
|
let selectedIdx = 0;
|
|
53
61
|
const descLines = description.split('\n');
|
|
54
62
|
const truncatable = descLines.length > MAX_DESC_LINES;
|
|
55
63
|
|
|
56
|
-
function
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
64
|
+
function buildModalLines() {
|
|
65
|
+
const lines = [];
|
|
66
|
+
const visible = truncatable
|
|
67
|
+
? descLines.slice(0, MAX_DESC_LINES).concat([` \x1b[2m… ${descLines.length - MAX_DESC_LINES} more lines\x1b[0m`])
|
|
68
|
+
: descLines;
|
|
69
|
+
// First description line gets the bullet glyph; continuation lines
|
|
70
|
+
// are indented to align under it. Matches the pre-modal rendering
|
|
71
|
+
// that went through chatHistory's system-message renderer.
|
|
72
|
+
const first = visible[0] || '';
|
|
73
|
+
lines.push(` \x1b[38;5;244m●\x1b[0m \x1b[38;5;244m${first}\x1b[0m`);
|
|
74
|
+
for (let i = 1; i < visible.length; i++) {
|
|
75
|
+
lines.push(` \x1b[38;5;244m${visible[i]}\x1b[0m`);
|
|
63
76
|
}
|
|
64
|
-
|
|
77
|
+
lines.push('');
|
|
65
78
|
for (let i = 0; i < options.length; i++) {
|
|
66
|
-
|
|
79
|
+
lines.push(i === selectedIdx
|
|
67
80
|
? `\x1b[1m\x1b[36m ► ${options[i]}\x1b[0m`
|
|
68
81
|
: ` ${options[i]}`
|
|
69
82
|
);
|
|
70
83
|
}
|
|
71
|
-
return
|
|
84
|
+
return lines;
|
|
72
85
|
}
|
|
73
86
|
|
|
74
|
-
|
|
75
|
-
|
|
87
|
+
onShowModal(buildModalLines());
|
|
88
|
+
|
|
89
|
+
function finish(result) {
|
|
90
|
+
const chosen = result === 'cancel' ? 'no' : options[selectedIdx].toLowerCase();
|
|
91
|
+
const glyph = (chosen === 'no') ? '✗' : '✓';
|
|
92
|
+
// The full `description` is preserved in the summary so multi-line
|
|
93
|
+
// bodies (e.g. file-write diffs) remain visible in scrollback after
|
|
94
|
+
// the modal closes. chatHistory's system-message renderer styles the
|
|
95
|
+
// first line by the leading glyph and indents continuations.
|
|
96
|
+
onCloseModal(`${glyph} ${description}`);
|
|
97
|
+
releaseQueue();
|
|
98
|
+
resolve(chosen);
|
|
99
|
+
}
|
|
76
100
|
|
|
77
101
|
const releaseNav = onCaptureNavigation((action) => {
|
|
78
102
|
if (action === 'next') {
|
|
79
103
|
selectedIdx = (selectedIdx + 1) % options.length;
|
|
80
|
-
|
|
81
|
-
onRerenderMessage(uniqueId);
|
|
104
|
+
onShowModal(buildModalLines());
|
|
82
105
|
} else if (action === 'prev') {
|
|
83
106
|
selectedIdx = (selectedIdx - 1 + options.length) % options.length;
|
|
84
|
-
|
|
85
|
-
onRerenderMessage(uniqueId);
|
|
107
|
+
onShowModal(buildModalLines());
|
|
86
108
|
} else if (action === 'select') {
|
|
87
|
-
const chosen = options[selectedIdx];
|
|
88
109
|
releaseNav();
|
|
89
|
-
|
|
90
|
-
onCollapseMessage(uniqueId);
|
|
91
|
-
releaseQueue();
|
|
92
|
-
resolve(chosen.toLowerCase());
|
|
110
|
+
finish('select');
|
|
93
111
|
} else if (action === 'cancel') {
|
|
94
112
|
releaseNav();
|
|
95
|
-
|
|
96
|
-
onCollapseMessage(uniqueId);
|
|
97
|
-
releaseQueue();
|
|
98
|
-
resolve('no');
|
|
113
|
+
finish('cancel');
|
|
99
114
|
}
|
|
100
115
|
});
|
|
101
116
|
}));
|
|
102
117
|
}
|
|
103
118
|
|
|
104
|
-
|
|
105
|
-
if (
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
console.log(` ${FG_GREEN}✓${RST} ${FG_DARK}Auto-approved: ${description}${RST}`);
|
|
110
|
-
}
|
|
111
|
-
return true;
|
|
119
|
+
function _emitAutoApproved(description) {
|
|
120
|
+
if (uiCallbacks) {
|
|
121
|
+
uiCallbacks.onAddMessage({ role: 'system', content: `✓ Auto-approved: ${description}` });
|
|
122
|
+
} else {
|
|
123
|
+
messages.sysSuccess(`Auto-approved: ${description}`);
|
|
112
124
|
}
|
|
125
|
+
}
|
|
113
126
|
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
} else {
|
|
118
|
-
console.log(` ${FG_GREEN}✓${RST} ${FG_DARK}Auto-approved: ${description}${RST}`);
|
|
119
|
-
}
|
|
127
|
+
async function askPermission(actionType, description, tag) {
|
|
128
|
+
if (state.autoApproveAll) {
|
|
129
|
+
_emitAutoApproved(description);
|
|
120
130
|
return true;
|
|
121
131
|
}
|
|
122
132
|
|
|
123
|
-
if (
|
|
124
|
-
|
|
125
|
-
uiCallbacks.onAddMessage({ role: 'system', content: `✓ Auto-approved: ${description}` });
|
|
126
|
-
} else {
|
|
127
|
-
console.log(` ${FG_GREEN}✓${RST} ${FG_DARK}Auto-approved: ${description}${RST}`);
|
|
128
|
-
}
|
|
133
|
+
if (tag && (autoApprovedTags.has(tag) || state.sessionApprovedTags.has(tag))) {
|
|
134
|
+
_emitAutoApproved(description);
|
|
129
135
|
return true;
|
|
130
136
|
}
|
|
131
137
|
|
|
132
138
|
if (!process.stdout.isTTY || !process.stdin.isTTY) {
|
|
133
|
-
|
|
139
|
+
writer.scrollback(` [non-TTY] Auto-approving: ${description}`);
|
|
134
140
|
return true;
|
|
135
141
|
}
|
|
136
142
|
|
|
137
143
|
if (uiCallbacks) {
|
|
138
144
|
const result = await requestPermission(
|
|
139
145
|
`${actionType}: ${description}`,
|
|
140
|
-
uiCallbacks.
|
|
141
|
-
uiCallbacks.
|
|
142
|
-
uiCallbacks.onCollapseMessage,
|
|
146
|
+
uiCallbacks.onShowModal,
|
|
147
|
+
uiCallbacks.onCloseModal,
|
|
143
148
|
uiCallbacks.onCaptureNavigation,
|
|
144
149
|
);
|
|
145
150
|
|
|
146
151
|
if (result === 'no') return false;
|
|
147
152
|
|
|
148
|
-
if (result === 'always') {
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
uiCallbacks.onAddMessage({ role: 'system', content: `✓ Auto-approve enabled for ${actionType} operations` });
|
|
153
|
+
if (result === 'always' && tag) {
|
|
154
|
+
state.sessionApprovedTags.add(tag);
|
|
155
|
+
uiCallbacks.onAddMessage({ role: 'system', content: `✓ Auto-approve enabled for \`${tag}\` this session` });
|
|
152
156
|
}
|
|
153
157
|
|
|
154
158
|
return true;
|
|
155
159
|
}
|
|
156
160
|
|
|
157
|
-
// Fallback:
|
|
158
|
-
const alwaysLabel =
|
|
161
|
+
// Fallback: TTY interactive select (used outside of chat UI)
|
|
162
|
+
const alwaysLabel = tag ? `Yes, always for <${tag}>` : 'Yes, always';
|
|
159
163
|
const choices = ['Yes', alwaysLabel, 'No'];
|
|
160
164
|
|
|
161
|
-
|
|
162
|
-
console.log(` ${FG_YELLOW}${BOLD}⚠ Permission required${RST}`);
|
|
163
|
-
console.log(` ${FG_GRAY}${actionType}: ${description}${RST}`);
|
|
165
|
+
writer.scrollback(`\n ${FG_YELLOW}${BOLD}⚠ Permission required${RST}\n ${FG_GRAY}${actionType}: ${description}${RST}`);
|
|
164
166
|
|
|
165
167
|
const selectedIndex = await interactiveSelect(
|
|
166
168
|
choices,
|
|
@@ -173,14 +175,13 @@ function createPermissionManager(ui, { allowedTiers = [], readonly = false } = {
|
|
|
173
175
|
);
|
|
174
176
|
|
|
175
177
|
if (selectedIndex === null || selectedIndex === 2) {
|
|
176
|
-
|
|
178
|
+
writer.scrollback(` ${FG_RED}✗${RST} ${FG_DARK}Denied${RST}`);
|
|
177
179
|
return false;
|
|
178
180
|
}
|
|
179
181
|
|
|
180
|
-
if (selectedIndex === 1) {
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
console.log(` ${FG_GREEN}✓${RST} ${FG_DARK}Auto-approve enabled for ${actionType} operations${RST}`);
|
|
182
|
+
if (selectedIndex === 1 && tag) {
|
|
183
|
+
state.sessionApprovedTags.add(tag);
|
|
184
|
+
writer.scrollback(` ${FG_GREEN}✓${RST} ${FG_DARK}Auto-approve enabled for <${tag}> this session${RST}`);
|
|
184
185
|
}
|
|
185
186
|
|
|
186
187
|
return true;
|
|
@@ -194,14 +195,13 @@ function createPermissionManager(ui, { allowedTiers = [], readonly = false } = {
|
|
|
194
195
|
}
|
|
195
196
|
|
|
196
197
|
function clear() {
|
|
197
|
-
state.
|
|
198
|
-
state.
|
|
198
|
+
state.autoApproveAll = false;
|
|
199
|
+
state.sessionApprovedTags.clear();
|
|
199
200
|
}
|
|
200
201
|
|
|
201
202
|
function toggleAll() {
|
|
202
|
-
state.
|
|
203
|
-
|
|
204
|
-
return state.autoApproveShell;
|
|
203
|
+
state.autoApproveAll = !state.autoApproveAll;
|
|
204
|
+
return state.autoApproveAll;
|
|
205
205
|
}
|
|
206
206
|
|
|
207
207
|
return {
|