skyloom 1.15.4 → 1.16.0
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/dist/cli/command_args.d.ts +74 -0
- package/dist/cli/command_args.d.ts.map +1 -0
- package/dist/cli/command_args.js +129 -0
- package/dist/cli/command_args.js.map +1 -0
- package/dist/cli/loom.d.ts +20 -0
- package/dist/cli/loom.d.ts.map +1 -1
- package/dist/cli/loom.js +202 -24
- package/dist/cli/loom.js.map +1 -1
- package/dist/cli/loom_chat.d.ts.map +1 -1
- package/dist/cli/loom_chat.js +39 -0
- package/dist/cli/loom_chat.js.map +1 -1
- package/dist/core/agent.js +2 -2
- package/dist/core/agent.js.map +1 -1
- package/dist/core/security.d.ts.map +1 -1
- package/dist/core/security.js +1 -0
- package/dist/core/security.js.map +1 -1
- package/dist/core/tool_router.d.ts.map +1 -1
- package/dist/core/tool_router.js +11 -3
- package/dist/core/tool_router.js.map +1 -1
- package/dist/tools/builtin.d.ts.map +1 -1
- package/dist/tools/builtin.js +38 -192
- package/dist/tools/builtin.js.map +1 -1
- package/dist/tools/websearch.d.ts +92 -0
- package/dist/tools/websearch.d.ts.map +1 -0
- package/dist/tools/websearch.js +343 -0
- package/dist/tools/websearch.js.map +1 -0
- package/dist/web/server.js +2 -9
- package/dist/web/server.js.map +1 -1
- package/dist/web/ui.d.ts.map +1 -1
- package/dist/web/ui.js +3 -2
- package/dist/web/ui.js.map +1 -1
- package/package.json +1 -1
- package/src/cli/command_args.ts +159 -0
- package/src/cli/loom.ts +155 -17
- package/src/cli/loom_chat.ts +33 -0
- package/src/core/agent.ts +2 -2
- package/src/core/security.ts +1 -0
- package/src/core/tool_router.ts +11 -3
- package/src/tools/builtin.ts +38 -190
- package/src/tools/websearch.ts +368 -0
- package/src/web/server.ts +2 -10
- package/src/web/ui.ts +3 -2
- package/tests/command_args.test.ts +115 -0
- package/tests/loom.test.ts +74 -0
- package/tests/tool_router.test.ts +15 -0
- package/tests/web.test.ts +7 -5
- package/tests/websearch.test.ts +190 -0
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 斜杠命令向导 · Cascading argument wizard for slash commands.
|
|
3
|
+
*
|
|
4
|
+
* After a slash command that takes structured arguments is chosen in the loom
|
|
5
|
+
* palette, the TUI walks the user through its arguments one level at a time:
|
|
6
|
+
* pick a provider, then paste a key; pick a model; pick a session. Each level is
|
|
7
|
+
* navigable with ↑/↓ and filterable by typing — the same affordance as the
|
|
8
|
+
* command palette itself, extended to arguments.
|
|
9
|
+
*
|
|
10
|
+
* This module is the pure brain of that flow (no I/O, no terminal) so it is
|
|
11
|
+
* fully unit-testable: given a command, the values chosen so far, and a snapshot
|
|
12
|
+
* of runtime context, it returns the next step — or null when the command is
|
|
13
|
+
* complete and ready to submit.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
export interface ArgChoice {
|
|
17
|
+
/** The value contributed to the final command line. */
|
|
18
|
+
value: string;
|
|
19
|
+
/** Display label in the list. */
|
|
20
|
+
label: string;
|
|
21
|
+
/** Optional dim hint shown after the label. */
|
|
22
|
+
hint?: string;
|
|
23
|
+
/** Optional group heading (e.g. provider name) for sectioned lists. */
|
|
24
|
+
group?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface WizardStep {
|
|
28
|
+
kind: 'choice' | 'freeform';
|
|
29
|
+
/** Heading shown above the list / prompt. */
|
|
30
|
+
title: string;
|
|
31
|
+
/** Choices for a 'choice' step (already ordered). */
|
|
32
|
+
choices: ArgChoice[];
|
|
33
|
+
/** A 'choice' step may also accept a typed value not in the list. */
|
|
34
|
+
allowFreeform: boolean;
|
|
35
|
+
/** Placeholder for a 'freeform' step (or a free-typed choice). */
|
|
36
|
+
placeholder?: string;
|
|
37
|
+
/** Mask typed input (API keys). */
|
|
38
|
+
secret?: boolean;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface WizardProvider { id: string; label: string; configured: boolean; envVar?: string }
|
|
42
|
+
export interface WizardModel { id: string; provider: string; label: string; hint?: string }
|
|
43
|
+
export interface WizardSession { id: string; label: string }
|
|
44
|
+
|
|
45
|
+
export interface WizardContext {
|
|
46
|
+
providers: WizardProvider[];
|
|
47
|
+
models: WizardModel[];
|
|
48
|
+
sessions: WizardSession[];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Commands that drive a guided wizard (base name without the leading slash). */
|
|
52
|
+
const WIZARD_COMMANDS = new Set(['model', 'apikey', 'connect', 'resume']);
|
|
53
|
+
|
|
54
|
+
/** Does this base command (with or without leading slash) have a wizard? */
|
|
55
|
+
export function hasWizard(command: string): boolean {
|
|
56
|
+
return WIZARD_COMMANDS.has(command.replace(/^\//, '').trim().toLowerCase());
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function providerChoices(ctx: WizardContext): ArgChoice[] {
|
|
60
|
+
return ctx.providers.map((p) => ({
|
|
61
|
+
value: p.id,
|
|
62
|
+
label: p.label,
|
|
63
|
+
hint: p.configured ? '✓ 已配置' : (p.envVar ? `需 ${p.envVar}` : '未配置'),
|
|
64
|
+
}));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function modelChoices(ctx: WizardContext): ArgChoice[] {
|
|
68
|
+
return ctx.models.map((m) => ({
|
|
69
|
+
value: m.id,
|
|
70
|
+
label: m.id,
|
|
71
|
+
hint: m.hint,
|
|
72
|
+
group: m.provider,
|
|
73
|
+
}));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* The next step for `command` given the values already chosen, or null when the
|
|
78
|
+
* command is complete (ready to submit via {@link buildCommandLine}).
|
|
79
|
+
*/
|
|
80
|
+
export function nextWizardStep(command: string, prior: string[], ctx: WizardContext): WizardStep | null {
|
|
81
|
+
const cmd = command.replace(/^\//, '').trim().toLowerCase();
|
|
82
|
+
|
|
83
|
+
switch (cmd) {
|
|
84
|
+
case 'model': {
|
|
85
|
+
if (prior.length >= 1) return null;
|
|
86
|
+
const choices: ArgChoice[] = [
|
|
87
|
+
{ value: 'reset', label: '↺ reset', hint: '回到统一默认模型' },
|
|
88
|
+
...modelChoices(ctx),
|
|
89
|
+
];
|
|
90
|
+
return { kind: 'choice', title: '选择模型(输入可筛选)', choices, allowFreeform: true, placeholder: '模型 id' };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
case 'connect': {
|
|
94
|
+
if (prior.length >= 1) return null;
|
|
95
|
+
return { kind: 'choice', title: '选择 Provider', choices: providerChoices(ctx), allowFreeform: true, placeholder: 'provider' };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
case 'apikey': {
|
|
99
|
+
// step 0: provider · step 1: the key
|
|
100
|
+
if (prior.length === 0) {
|
|
101
|
+
return { kind: 'choice', title: '为哪个 Provider 配置 API Key', choices: providerChoices(ctx), allowFreeform: true, placeholder: 'provider' };
|
|
102
|
+
}
|
|
103
|
+
if (prior.length === 1) {
|
|
104
|
+
return { kind: 'freeform', title: `粘贴 ${prior[0]} 的 API Key`, choices: [], allowFreeform: true, placeholder: 'sk-…(回车保存)', secret: true };
|
|
105
|
+
}
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
case 'resume': {
|
|
110
|
+
if (prior.length >= 1) return null;
|
|
111
|
+
const choices: ArgChoice[] = ctx.sessions.map((s, i) => ({ value: String(i + 1), label: `${i + 1}. ${s.label}`, hint: s.id.slice(0, 8) }));
|
|
112
|
+
return { kind: 'choice', title: choices.length ? '选择要恢复的会话' : '暂无历史会话', choices, allowFreeform: true, placeholder: '序号或 id' };
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Assemble the final command line from the base command + chosen values. */
|
|
119
|
+
export function buildCommandLine(command: string, values: string[]): string {
|
|
120
|
+
const cmd = command.replace(/^\//, '').trim().toLowerCase();
|
|
121
|
+
const v = values.filter((x) => x !== undefined && x !== null);
|
|
122
|
+
switch (cmd) {
|
|
123
|
+
case 'apikey':
|
|
124
|
+
// /apikey set <provider> <key>
|
|
125
|
+
return `/apikey set ${v.join(' ')}`.trim();
|
|
126
|
+
case 'model':
|
|
127
|
+
return `/model ${v.join(' ')}`.trim();
|
|
128
|
+
case 'connect':
|
|
129
|
+
return `/connect ${v.join(' ')}`.trim();
|
|
130
|
+
case 'resume':
|
|
131
|
+
return `/resume ${v.join(' ')}`.trim();
|
|
132
|
+
default:
|
|
133
|
+
return `/${cmd} ${v.join(' ')}`.trim();
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Filter + rank choices by a typed query (case-insensitive substring on value,
|
|
139
|
+
* label, and group). Empty query returns the list unchanged. Exact value/label
|
|
140
|
+
* prefix matches sort first so the obvious pick lands at the top.
|
|
141
|
+
*/
|
|
142
|
+
export function filterChoices(choices: ArgChoice[], typed: string): ArgChoice[] {
|
|
143
|
+
const q = typed.trim().toLowerCase();
|
|
144
|
+
if (!q) return choices;
|
|
145
|
+
const scored: Array<{ c: ArgChoice; rank: number }> = [];
|
|
146
|
+
for (const c of choices) {
|
|
147
|
+
const value = c.value.toLowerCase();
|
|
148
|
+
const label = c.label.toLowerCase();
|
|
149
|
+
const group = (c.group || '').toLowerCase();
|
|
150
|
+
let rank = -1;
|
|
151
|
+
if (value === q || label === q) rank = 0;
|
|
152
|
+
else if (value.startsWith(q) || label.startsWith(q)) rank = 1;
|
|
153
|
+
else if (value.includes(q) || label.includes(q)) rank = 2;
|
|
154
|
+
else if (group.includes(q)) rank = 3;
|
|
155
|
+
if (rank >= 0) scored.push({ c, rank });
|
|
156
|
+
}
|
|
157
|
+
scored.sort((a, b) => a.rank - b.rank); // stable within equal ranks
|
|
158
|
+
return scored.map((s) => s.c);
|
|
159
|
+
}
|
package/src/cli/loom.ts
CHANGED
|
@@ -30,6 +30,7 @@ import * as readline from "readline";
|
|
|
30
30
|
import chalk from "chalk";
|
|
31
31
|
import { agentTheme, AGENT_ORDER, PALETTE } from "../core/theme";
|
|
32
32
|
import { charWidth, visualWidth, SLASH_COMMANDS } from "./tui";
|
|
33
|
+
import { hasWizard, buildCommandLine, filterChoices, type WizardStep, type ArgChoice } from "./command_args";
|
|
33
34
|
|
|
34
35
|
/* ════════════════════════════════════════
|
|
35
36
|
ANSI-aware string helpers (pure, tested)
|
|
@@ -409,6 +410,14 @@ export class LoomUI {
|
|
|
409
410
|
modeBadge = "";
|
|
410
411
|
/** User-defined slash commands shown in the palette ([name, description]). */
|
|
411
412
|
extraCommands: [string, string][] = [];
|
|
413
|
+
/**
|
|
414
|
+
* Cascading argument wizard, active after a structured slash command is
|
|
415
|
+
* chosen (e.g. /apikey → pick provider → paste key). Resolved step-by-step
|
|
416
|
+
* via the wizardStep callback the chat loop wires up.
|
|
417
|
+
*/
|
|
418
|
+
private wizard: { command: string; values: string[]; step: WizardStep; typed: string; idx: number } | null = null;
|
|
419
|
+
/** Next-step resolver for the argument wizard (set by the chat loop with runtime context). */
|
|
420
|
+
wizardStep: ((command: string, prior: string[]) => WizardStep | null) | null = null;
|
|
412
421
|
private keypressHandler: ((str: string, key: any) => void) | null = null;
|
|
413
422
|
private resizeHandler: (() => void) | null = null;
|
|
414
423
|
|
|
@@ -622,6 +631,9 @@ export class LoomUI {
|
|
|
622
631
|
const name = key?.name;
|
|
623
632
|
if (key?.ctrl && name === "c") { this.handleSigint(); return; }
|
|
624
633
|
|
|
634
|
+
// The argument wizard owns all keys while it is open.
|
|
635
|
+
if (this.wizard && !this.busy) { this.handleWizardKey(str, key); return; }
|
|
636
|
+
|
|
625
637
|
if (name === "pageup") { this.scrollOff += Math.max(1, this.bodyH() - 2); this.clampScroll(); this.paint(); return; }
|
|
626
638
|
if (name === "pagedown") { this.scrollOff -= Math.max(1, this.bodyH() - 2); this.clampScroll(); this.paint(); return; }
|
|
627
639
|
|
|
@@ -630,15 +642,14 @@ export class LoomUI {
|
|
|
630
642
|
let text = this.inputGlyphs.join("").trim();
|
|
631
643
|
|
|
632
644
|
// Palette open: Enter runs the ↑↓-highlighted command (Claude Code
|
|
633
|
-
// style).
|
|
634
|
-
//
|
|
645
|
+
// style). A command with a guided argument wizard opens the wizard;
|
|
646
|
+
// otherwise an argument-taking command fills the input to wait for input.
|
|
635
647
|
const matches = this.paletteMatches();
|
|
636
648
|
if (matches.length > 0 && text.startsWith("/")) {
|
|
637
649
|
const [cmd] = matches[Math.max(0, Math.min(this.paletteIdx, matches.length - 1))];
|
|
650
|
+
if (this.startWizard(cmd.trim())) return;
|
|
638
651
|
if (cmd.endsWith(" ")) {
|
|
639
|
-
// argument-taking command: fill the input and wait
|
|
640
|
-
// (the palette closes once the line contains a space; a second
|
|
641
|
-
// Enter then submits as typed)
|
|
652
|
+
// argument-taking command without a wizard: fill the input and wait.
|
|
642
653
|
this.inputGlyphs = [...cmd];
|
|
643
654
|
this.cursor = this.inputGlyphs.length;
|
|
644
655
|
this.paletteIdx = 0;
|
|
@@ -648,13 +659,7 @@ export class LoomUI {
|
|
|
648
659
|
text = cmd.trimEnd();
|
|
649
660
|
}
|
|
650
661
|
|
|
651
|
-
this.
|
|
652
|
-
this.scrollOff = 0; // submitting a turn snaps back to the tail to watch the reply
|
|
653
|
-
if (text) { this.history.unshift(text); if (this.history.length > 200) this.history.pop(); }
|
|
654
|
-
const r = this.pendingResolve;
|
|
655
|
-
this.pendingResolve = null;
|
|
656
|
-
this.paint();
|
|
657
|
-
if (r) r(text);
|
|
662
|
+
this.submitText(text);
|
|
658
663
|
return;
|
|
659
664
|
}
|
|
660
665
|
|
|
@@ -683,7 +688,10 @@ export class LoomUI {
|
|
|
683
688
|
if (paletteOpen) {
|
|
684
689
|
const m = this.paletteMatches();
|
|
685
690
|
const pick = m[Math.min(this.paletteIdx, m.length - 1)];
|
|
686
|
-
if (pick) {
|
|
691
|
+
if (pick) {
|
|
692
|
+
if (this.startWizard(pick[0].trim())) return;
|
|
693
|
+
this.inputGlyphs = [...pick[0].trimEnd()]; this.cursor = this.inputGlyphs.length;
|
|
694
|
+
}
|
|
687
695
|
}
|
|
688
696
|
this.paint(); return;
|
|
689
697
|
}
|
|
@@ -744,6 +752,91 @@ export class LoomUI {
|
|
|
744
752
|
|
|
745
753
|
private flashHint = "";
|
|
746
754
|
|
|
755
|
+
/* ── argument wizard ── */
|
|
756
|
+
|
|
757
|
+
/** Submit a turn: clear input, record history, resolve the pending read. */
|
|
758
|
+
private submitText(text: string) {
|
|
759
|
+
this.wizard = null;
|
|
760
|
+
this.inputGlyphs = []; this.cursor = 0; this.histIdx = -1; this.paletteIdx = 0;
|
|
761
|
+
this.scrollOff = 0; // submitting a turn snaps back to the tail to watch the reply
|
|
762
|
+
if (text) { this.history.unshift(text); if (this.history.length > 200) this.history.pop(); }
|
|
763
|
+
const r = this.pendingResolve;
|
|
764
|
+
this.pendingResolve = null;
|
|
765
|
+
this.paint();
|
|
766
|
+
if (r) r(text);
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
/** Choices for the current wizard step, filtered by what the user has typed. */
|
|
770
|
+
private wizardFiltered(): ArgChoice[] {
|
|
771
|
+
if (!this.wizard || this.wizard.step.kind !== "choice") return [];
|
|
772
|
+
return filterChoices(this.wizard.step.choices, this.wizard.typed);
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
/** Open the wizard for `command` (e.g. "/model"). Returns false if it has none. */
|
|
776
|
+
private startWizard(command: string): boolean {
|
|
777
|
+
if (!this.wizardStep || !hasWizard(command)) return false;
|
|
778
|
+
const step = this.wizardStep(command, []);
|
|
779
|
+
if (!step) return false;
|
|
780
|
+
this.wizard = { command, values: [], step, typed: "", idx: 0 };
|
|
781
|
+
this.inputGlyphs = []; this.cursor = 0; this.paletteIdx = 0;
|
|
782
|
+
this.paint();
|
|
783
|
+
return true;
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
/** Accept a value for the current step; advance to the next or submit. */
|
|
787
|
+
private wizardCommit(value: string) {
|
|
788
|
+
const w = this.wizard!;
|
|
789
|
+
const values = [...w.values, value];
|
|
790
|
+
const next = this.wizardStep ? this.wizardStep(w.command, values) : null;
|
|
791
|
+
if (next) {
|
|
792
|
+
this.wizard = { command: w.command, values, step: next, typed: "", idx: 0 };
|
|
793
|
+
this.paint();
|
|
794
|
+
} else {
|
|
795
|
+
this.submitText(buildCommandLine(w.command, values));
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
/** Step back one level (or close the wizard when at the first level). */
|
|
800
|
+
private wizardBack() {
|
|
801
|
+
const w = this.wizard!;
|
|
802
|
+
if (w.values.length === 0 || !this.wizardStep) { this.wizard = null; this.paint(); return; }
|
|
803
|
+
const values = w.values.slice(0, -1);
|
|
804
|
+
const step = this.wizardStep(w.command, values);
|
|
805
|
+
if (!step) { this.wizard = null; this.paint(); return; }
|
|
806
|
+
this.wizard = { command: w.command, values, step, typed: "", idx: 0 };
|
|
807
|
+
this.paint();
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
private handleWizardKey(str: string, key: any) {
|
|
811
|
+
const w = this.wizard!;
|
|
812
|
+
const name = key?.name;
|
|
813
|
+
if (name === "escape") { this.wizard = null; this.paint(); return; }
|
|
814
|
+
if (name === "up") { if (w.step.kind === "choice") w.idx = Math.max(0, w.idx - 1); this.paint(); return; }
|
|
815
|
+
if (name === "down") {
|
|
816
|
+
if (w.step.kind === "choice") w.idx = Math.min(Math.max(0, this.wizardFiltered().length - 1), w.idx + 1);
|
|
817
|
+
this.paint(); return;
|
|
818
|
+
}
|
|
819
|
+
if (name === "return" || name === "tab") {
|
|
820
|
+
if (w.step.kind === "choice") {
|
|
821
|
+
const filtered = this.wizardFiltered();
|
|
822
|
+
const pick = filtered[Math.min(w.idx, filtered.length - 1)];
|
|
823
|
+
if (pick) { this.wizardCommit(pick.value); return; }
|
|
824
|
+
if (w.step.allowFreeform && w.typed.trim()) { this.wizardCommit(w.typed.trim()); return; }
|
|
825
|
+
return;
|
|
826
|
+
}
|
|
827
|
+
if (w.typed.trim()) this.wizardCommit(w.typed.trim());
|
|
828
|
+
return;
|
|
829
|
+
}
|
|
830
|
+
if (name === "backspace") {
|
|
831
|
+
if (w.typed.length > 0) { w.typed = w.typed.slice(0, -1); w.idx = 0; this.paint(); return; }
|
|
832
|
+
this.wizardBack(); return;
|
|
833
|
+
}
|
|
834
|
+
if (str && !key?.ctrl && !key?.meta) {
|
|
835
|
+
const glyphs = [...str].filter((c) => c >= " " || charWidth(c.codePointAt(0)!) > 0);
|
|
836
|
+
if (glyphs.length) { w.typed += glyphs.join(""); w.idx = 0; this.paint(); }
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
|
|
747
840
|
private paletteMatches(): [string, string][] {
|
|
748
841
|
const l = this.inputGlyphs.join("");
|
|
749
842
|
if (!l.startsWith("/") || l.includes(" ")) return [];
|
|
@@ -936,6 +1029,13 @@ export class LoomUI {
|
|
|
936
1029
|
if (this.modal) {
|
|
937
1030
|
content = " " + chalk.yellow("⚠ ") + cutVisual(this.modal.text, innerW - 14) + chalk.bold(" 允许? ") + chalk.dim("[y/N]");
|
|
938
1031
|
cursorPos = { row: rows - 2, col: Math.min(innerW, visualWidth(content) + 1) };
|
|
1032
|
+
} else if (this.wizard) {
|
|
1033
|
+
const w = this.wizard;
|
|
1034
|
+
const crumb = [w.command.replace(/^\//, ""), ...w.values].join(" ");
|
|
1035
|
+
const shownTyped = w.step.secret ? "•".repeat([...w.typed].length) : w.typed;
|
|
1036
|
+
const head = chalk.hex(t.hex)(` ${t.symbol} `) + chalk.hex(PALETTE.inkLight)(crumb + " ▸ ");
|
|
1037
|
+
content = head + cutVisual(shownTyped, innerW - visualWidth(head) - 2);
|
|
1038
|
+
cursorPos = { row: rows - 2, col: Math.min(innerW, visualWidth(content) + 1) };
|
|
939
1039
|
} else {
|
|
940
1040
|
const promptStr = chalk.hex(t.hex)(` ${t.symbol} `) + chalk.hex(PALETTE.inkLight)("❯ ");
|
|
941
1041
|
const promptW = visualWidth(promptStr);
|
|
@@ -966,17 +1066,55 @@ export class LoomUI {
|
|
|
966
1066
|
const paletteUp = this.paletteMatches().length > 0 && this.inputGlyphs[0] === "/";
|
|
967
1067
|
const hint = this.busy
|
|
968
1068
|
? " Ctrl-C 中断本轮 "
|
|
969
|
-
:
|
|
970
|
-
?
|
|
971
|
-
|
|
1069
|
+
: this.wizard
|
|
1070
|
+
? (this.wizard.step.kind === "choice"
|
|
1071
|
+
? " ↑↓ 选择 · Enter 确认 · 输入筛选 · ⌫ 返回 · Esc 取消 "
|
|
1072
|
+
: " 输入后 Enter 确认 · ⌫ 返回上一步 · Esc 取消 ")
|
|
1073
|
+
: paletteUp
|
|
1074
|
+
? " ↑↓ 选命令 · Enter 执行 · Tab 补全 · Esc 收起 "
|
|
1075
|
+
: " / 命令 · 滚轮/PgUp 回看 · Shift+Tab 切模式 · Ctrl-C 退出 ";
|
|
972
1076
|
// └─ hint ───…┘ → 2 + w(hint) + fill + 1 = cols
|
|
973
1077
|
const fill = innerW - visualWidth(hint) - 1;
|
|
974
1078
|
frame.push(B("└─") + chalk.dim(hint) + B("─".repeat(Math.max(0, fill)) + "┘"));
|
|
975
1079
|
}
|
|
976
1080
|
|
|
1081
|
+
// ── argument wizard: overlay the title + selectable choices ──
|
|
1082
|
+
if (this.wizard && !this.modal) {
|
|
1083
|
+
const w = this.wizard;
|
|
1084
|
+
const overlayRow = (row: number, s: string) => {
|
|
1085
|
+
if (row < 1 + SKY_H || row >= 1 + SKY_H + bodyH) return;
|
|
1086
|
+
frame[row] = B("│") + padAnsi(rail[row - 1 - SKY_H] ?? "", RAIL_W) + B("│") + " " + padAnsi(s, this.viewW()) + B("│");
|
|
1087
|
+
};
|
|
1088
|
+
const lines: string[] = [];
|
|
1089
|
+
lines.push(chalk.dim(" " + cutVisual(w.step.title, this.viewW() - 4)));
|
|
1090
|
+
if (w.step.kind === "choice") {
|
|
1091
|
+
const filtered = this.wizardFiltered();
|
|
1092
|
+
const bodyRows = Math.min(7, bodyH - 1);
|
|
1093
|
+
if (!filtered.length) {
|
|
1094
|
+
lines.push(" " + chalk.dim(w.step.allowFreeform ? `直接输入,回车确认` : `无匹配项`));
|
|
1095
|
+
} else {
|
|
1096
|
+
w.idx = Math.max(0, Math.min(w.idx, filtered.length - 1));
|
|
1097
|
+
const start = Math.max(0, Math.min(w.idx - bodyRows + 1, filtered.length - bodyRows));
|
|
1098
|
+
filtered.slice(start, start + bodyRows).forEach((c, i) => {
|
|
1099
|
+
const sel = start + i === w.idx;
|
|
1100
|
+
const mark = sel ? chalk.hex(t.hex)(" ▸ ") : " ";
|
|
1101
|
+
const group = c.group ? chalk.dim(`${c.group}/`) : "";
|
|
1102
|
+
const label = sel ? chalk.bold.hex(t.hex)(c.label) : chalk.hex(PALETTE.inkLight)(c.label);
|
|
1103
|
+
const hint = c.hint ? chalk.dim(" " + c.hint) : "";
|
|
1104
|
+
const counter = sel && filtered.length > bodyRows ? chalk.dim(` ${w.idx + 1}/${filtered.length}`) : "";
|
|
1105
|
+
lines.push(mark + group + cutVisual(label + hint, this.viewW() - 8) + counter);
|
|
1106
|
+
});
|
|
1107
|
+
}
|
|
1108
|
+
} else {
|
|
1109
|
+
lines.push(" " + chalk.dim(w.step.placeholder || "输入后回车确认"));
|
|
1110
|
+
}
|
|
1111
|
+
const baseRow = 1 + SKY_H + bodyH - lines.length;
|
|
1112
|
+
lines.forEach((s, i) => overlayRow(baseRow + i, s));
|
|
1113
|
+
}
|
|
1114
|
+
|
|
977
1115
|
// ── slash palette: overlay onto the rows just above the divider ──
|
|
978
1116
|
const matches = this.paletteMatches();
|
|
979
|
-
if (matches.length > 0 && this.inputGlyphs[0] === "/" && !this.modal) {
|
|
1117
|
+
if (!this.wizard && matches.length > 0 && this.inputGlyphs[0] === "/" && !this.modal) {
|
|
980
1118
|
const maxShow = Math.min(8, bodyH - 1);
|
|
981
1119
|
this.paletteIdx = Math.max(0, Math.min(this.paletteIdx, matches.length - 1));
|
|
982
1120
|
// scroll window that keeps the ↑↓ selection visible
|
package/src/cli/loom_chat.ts
CHANGED
|
@@ -320,6 +320,39 @@ export async function loomChat(ctx: any, startAgent: any, deps: LoomChatDeps): P
|
|
|
320
320
|
let customCommands = loadCustomCommands();
|
|
321
321
|
ui.extraCommands = customCommands.map((c) => ["/" + c.name, c.description] as [string, string]);
|
|
322
322
|
|
|
323
|
+
// Pre-load the session list so the /resume wizard has choices from the start.
|
|
324
|
+
try { lastSessions = await agent.memory.listSessions(); } catch { /* best-effort */ }
|
|
325
|
+
|
|
326
|
+
// Guided argument wizard for structured commands (/model · /apikey · /connect · /resume):
|
|
327
|
+
// pick a provider/model/session from a ↑↓ list, paste a key — no syntax to memorize.
|
|
328
|
+
ui.wizardStep = (command, prior) => {
|
|
329
|
+
try {
|
|
330
|
+
const { nextWizardStep } = require("./command_args");
|
|
331
|
+
const { listProviders, modelsFor, providerLabel, allModels } = require("../core/catalog");
|
|
332
|
+
const { loadConfig } = require("../core/config");
|
|
333
|
+
const cfg = loadConfig();
|
|
334
|
+
const configured = (p: string): boolean => {
|
|
335
|
+
const meta = PROVIDER_META[p];
|
|
336
|
+
if (meta?.envVar && process.env[meta.envVar]) return true;
|
|
337
|
+
if (cfg?.api_keys?.[p]) return true;
|
|
338
|
+
const models = modelsFor(p);
|
|
339
|
+
return models.length > 0 && models.every((m: any) => m.local); // local providers need no key
|
|
340
|
+
};
|
|
341
|
+
const providers = listProviders().map((p: string) => ({
|
|
342
|
+
id: p, label: providerLabel(p), configured: configured(p), envVar: PROVIDER_META[p]?.envVar,
|
|
343
|
+
}));
|
|
344
|
+
const models = allModels().map((m: any) => ({
|
|
345
|
+
id: m.id, provider: m.provider, label: m.id,
|
|
346
|
+
hint: m.local ? "本地/免费" : (m.costIn != null ? `$${m.costIn}/$${m.costOut}` : undefined),
|
|
347
|
+
}));
|
|
348
|
+
const sessions = lastSessions.map((s: any) => ({
|
|
349
|
+
id: String(s.id),
|
|
350
|
+
label: (s.preview || "(空)").replace(/\s+/g, " ").slice(0, 40),
|
|
351
|
+
}));
|
|
352
|
+
return nextWizardStep(command, prior, { providers, models, sessions });
|
|
353
|
+
} catch { return null; }
|
|
354
|
+
};
|
|
355
|
+
|
|
323
356
|
try {
|
|
324
357
|
while (true) {
|
|
325
358
|
const inp = await ui.readInput();
|
package/src/core/agent.ts
CHANGED
|
@@ -199,10 +199,10 @@ export class BaseAgent {
|
|
|
199
199
|
const lang = (this.config as any).llm?.language || 'zh';
|
|
200
200
|
if (lang === 'en') {
|
|
201
201
|
return prompt +
|
|
202
|
-
`\n\n## Thinking Protocol\nBefore acting, briefly weigh: (1) **What** is the actual need? (2) **How** sure am I? If <80%, flag with [uncertain] and ask.\nIf stuck, admit it — propose a partial answer or ask the user. Never fabricate.\n\n## Behavior\n- Act, don't narrate. No "I will..." before tool calls.\n- Stay in scope. Do what's asked, then stop.\n- Batch independent tool calls in one response.\n- For tasks with 3+ steps, plan with todo_write first and update item status as you go.\n- Verify writes: read back, report verified state.\n- Call list_skills when the task needs specialized capabilities.`;
|
|
202
|
+
`\n\n## Thinking Protocol\nBefore acting, briefly weigh: (1) **What** is the actual need? (2) **How** sure am I? If <80%, flag with [uncertain] and ask.\nIf stuck, admit it — propose a partial answer or ask the user. Never fabricate.\n\n## Behavior\n- Act, don't narrate. No "I will..." before tool calls.\n- Stay in scope. Do what's asked, then stop.\n- Batch independent tool calls in one response.\n- For tasks with 3+ steps, plan with todo_write first and update item status as you go.\n- Verify writes: read back, report verified state.\n- For anything current or real-time (today's news/hot topics, recent events, latest versions, prices, weather), call web_search FIRST, then read_url for detail. Never answer from memory or claim you can't go online.\n- Call list_skills when the task needs specialized capabilities.`;
|
|
203
203
|
}
|
|
204
204
|
return prompt +
|
|
205
|
-
`\n\n## 思考协议\n行动前快速判断:(1) 用户真实需求是什么?(2) 我有多大把握?低于80%标注 [不确定] 并主动询问。\n卡住时承认,给出部分答案或请求用户指导。绝不编造。\n\n## 行为守则\n- 直接行动,不预告。不说「我将要...」,直接调用工具\n- 不擅自扩大范围。用户要什么做什么,核心完成即止\n- 独立的工具调用一次发出,并行执行\n- 3 步以上的任务先用 todo_write 列任务清单,开工/完成时逐项更新状态\n- 写入后回读验证,汇报已验证状态而非仅尝试\n- 任务涉及专业能力时(PPT/Excel/PDF/网页设计/代码审查等),先调 list_skills 查看可用技能,再用 use_skill 激活`;
|
|
205
|
+
`\n\n## 思考协议\n行动前快速判断:(1) 用户真实需求是什么?(2) 我有多大把握?低于80%标注 [不确定] 并主动询问。\n卡住时承认,给出部分答案或请求用户指导。绝不编造。\n\n## 行为守则\n- 直接行动,不预告。不说「我将要...」,直接调用工具\n- 不擅自扩大范围。用户要什么做什么,核心完成即止\n- 独立的工具调用一次发出,并行执行\n- 3 步以上的任务先用 todo_write 列任务清单,开工/完成时逐项更新状态\n- 写入后回读验证,汇报已验证状态而非仅尝试\n- 凡涉及最新/实时信息(今日新闻热点、近期事件、最新版本、价格、天气等)一律先调 web_search 联网核实,再用 read_url 读全文;绝不凭记忆作答,也不要声称无法联网\n- 任务涉及专业能力时(PPT/Excel/PDF/网页设计/代码审查等),先调 list_skills 查看可用技能,再用 use_skill 激活`;
|
|
206
206
|
}
|
|
207
207
|
|
|
208
208
|
protected injectProgrammingWisdom(prompt: string): string {
|
package/src/core/security.ts
CHANGED
|
@@ -89,6 +89,7 @@ const TOOL_DANGER_MAP: Record<string, DangerLevel> = {
|
|
|
89
89
|
git_branch: DangerLevel.LOW,
|
|
90
90
|
http_get: DangerLevel.LOW,
|
|
91
91
|
fetch_page: DangerLevel.LOW,
|
|
92
|
+
read_url: DangerLevel.LOW,
|
|
92
93
|
web_search: DangerLevel.LOW,
|
|
93
94
|
remember_fact: DangerLevel.LOW,
|
|
94
95
|
use_skill: DangerLevel.LOW,
|
package/src/core/tool_router.ts
CHANGED
|
@@ -101,9 +101,17 @@ function scoreTool(tool: ToolDefinition, queryTokens: Set<string>, queryLc: stri
|
|
|
101
101
|
['git', 'commit', 'diff', 'branch', '提交', '分支', '差异'].some(k => queryLc.includes(k))) {
|
|
102
102
|
score += 4;
|
|
103
103
|
}
|
|
104
|
-
if (['web_search', 'fetch_page', 'http_get'].includes(name) &&
|
|
105
|
-
[
|
|
106
|
-
|
|
104
|
+
if (['web_search', 'read_url', 'fetch_page', 'http_get'].includes(name) &&
|
|
105
|
+
[
|
|
106
|
+
// explicit web/search intent
|
|
107
|
+
'web', 'url', 'http', 'research', '搜索', '搜', '网页', '联网', '上网', '资料', '查询', '查一下', '查查',
|
|
108
|
+
// time-sensitive / current-events intent — the reason "今日热点新闻" used to
|
|
109
|
+
// miss web_search entirely (it scored 0 and never made the tool shortlist)
|
|
110
|
+
'news', 'today', 'latest', 'current', 'recent', 'now', 'breaking', 'trending', 'weather', 'price', 'stock',
|
|
111
|
+
'新闻', '今日', '今天', '最新', '近期', '实时', '热点', '热搜', '头条', '动态', '行情', '股价', '汇率', '天气', '比分', '发布',
|
|
112
|
+
'2024', '2025', '2026',
|
|
113
|
+
].some(k => queryLc.includes(k))) {
|
|
114
|
+
score += 5;
|
|
107
115
|
}
|
|
108
116
|
if (['list_skills', 'use_skill'].includes(name) &&
|
|
109
117
|
['skill', '能力', '技能', 'ppt', 'pdf', 'excel', 'xlsx', 'docx'].some(k => queryLc.includes(k))) {
|