mirror of
https://github.com/QwenLM/qwen-code.git
synced 2025-12-19 09:33:53 +00:00
Merge pull request #1059 from yiliang114/feat/jinjing/write-and-read-file-in-vscode
feat: VSCode Extension Implementation
This commit is contained in:
@@ -1,5 +1,26 @@
|
||||
This file contains third-party software notices and license terms.
|
||||
|
||||
============================================================
|
||||
semver@7.7.2
|
||||
(git+https://github.com/npm/node-semver.git)
|
||||
|
||||
The ISC License
|
||||
|
||||
Copyright (c) Isaac Z. Schlueter and Contributors
|
||||
|
||||
Permission to use, copy, modify, and/or distribute this software for any
|
||||
purpose with or without fee is hereby granted, provided that the above
|
||||
copyright notice and this permission notice appear in all copies.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
|
||||
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
|
||||
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
|
||||
ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
|
||||
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
|
||||
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR
|
||||
IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
|
||||
============================================================
|
||||
@modelcontextprotocol/sdk@1.15.1
|
||||
(git+https://github.com/modelcontextprotocol/typescript-sdk.git)
|
||||
@@ -2317,3 +2338,520 @@ WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
|
||||
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
|
||||
OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
============================================================
|
||||
markdown-it@14.1.0
|
||||
(No repository found)
|
||||
|
||||
Copyright (c) 2014 Vitaly Puzrin, Alex Kocharin.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person
|
||||
obtaining a copy of this software and associated documentation
|
||||
files (the "Software"), to deal in the Software without
|
||||
restriction, including without limitation the rights to use,
|
||||
copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the
|
||||
Software is furnished to do so, subject to the following
|
||||
conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be
|
||||
included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
|
||||
OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
||||
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
|
||||
HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
|
||||
OTHER DEALINGS IN THE SOFTWARE.
|
||||
|
||||
|
||||
============================================================
|
||||
argparse@2.0.1
|
||||
(No repository found)
|
||||
|
||||
A. HISTORY OF THE SOFTWARE
|
||||
==========================
|
||||
|
||||
Python was created in the early 1990s by Guido van Rossum at Stichting
|
||||
Mathematisch Centrum (CWI, see http://www.cwi.nl) in the Netherlands
|
||||
as a successor of a language called ABC. Guido remains Python's
|
||||
principal author, although it includes many contributions from others.
|
||||
|
||||
In 1995, Guido continued his work on Python at the Corporation for
|
||||
National Research Initiatives (CNRI, see http://www.cnri.reston.va.us)
|
||||
in Reston, Virginia where he released several versions of the
|
||||
software.
|
||||
|
||||
In May 2000, Guido and the Python core development team moved to
|
||||
BeOpen.com to form the BeOpen PythonLabs team. In October of the same
|
||||
year, the PythonLabs team moved to Digital Creations, which became
|
||||
Zope Corporation. In 2001, the Python Software Foundation (PSF, see
|
||||
https://www.python.org/psf/) was formed, a non-profit organization
|
||||
created specifically to own Python-related Intellectual Property.
|
||||
Zope Corporation was a sponsoring member of the PSF.
|
||||
|
||||
All Python releases are Open Source (see http://www.opensource.org for
|
||||
the Open Source Definition). Historically, most, but not all, Python
|
||||
releases have also been GPL-compatible; the table below summarizes
|
||||
the various releases.
|
||||
|
||||
Release Derived Year Owner GPL-
|
||||
from compatible? (1)
|
||||
|
||||
0.9.0 thru 1.2 1991-1995 CWI yes
|
||||
1.3 thru 1.5.2 1.2 1995-1999 CNRI yes
|
||||
1.6 1.5.2 2000 CNRI no
|
||||
2.0 1.6 2000 BeOpen.com no
|
||||
1.6.1 1.6 2001 CNRI yes (2)
|
||||
2.1 2.0+1.6.1 2001 PSF no
|
||||
2.0.1 2.0+1.6.1 2001 PSF yes
|
||||
2.1.1 2.1+2.0.1 2001 PSF yes
|
||||
2.1.2 2.1.1 2002 PSF yes
|
||||
2.1.3 2.1.2 2002 PSF yes
|
||||
2.2 and above 2.1.1 2001-now PSF yes
|
||||
|
||||
Footnotes:
|
||||
|
||||
(1) GPL-compatible doesn't mean that we're distributing Python under
|
||||
the GPL. All Python licenses, unlike the GPL, let you distribute
|
||||
a modified version without making your changes open source. The
|
||||
GPL-compatible licenses make it possible to combine Python with
|
||||
other software that is released under the GPL; the others don't.
|
||||
|
||||
(2) According to Richard Stallman, 1.6.1 is not GPL-compatible,
|
||||
because its license has a choice of law clause. According to
|
||||
CNRI, however, Stallman's lawyer has told CNRI's lawyer that 1.6.1
|
||||
is "not incompatible" with the GPL.
|
||||
|
||||
Thanks to the many outside volunteers who have worked under Guido's
|
||||
direction to make these releases possible.
|
||||
|
||||
|
||||
B. TERMS AND CONDITIONS FOR ACCESSING OR OTHERWISE USING PYTHON
|
||||
===============================================================
|
||||
|
||||
PYTHON SOFTWARE FOUNDATION LICENSE VERSION 2
|
||||
--------------------------------------------
|
||||
|
||||
1. This LICENSE AGREEMENT is between the Python Software Foundation
|
||||
("PSF"), and the Individual or Organization ("Licensee") accessing and
|
||||
otherwise using this software ("Python") in source or binary form and
|
||||
its associated documentation.
|
||||
|
||||
2. Subject to the terms and conditions of this License Agreement, PSF hereby
|
||||
grants Licensee a nonexclusive, royalty-free, world-wide license to reproduce,
|
||||
analyze, test, perform and/or display publicly, prepare derivative works,
|
||||
distribute, and otherwise use Python alone or in any derivative version,
|
||||
provided, however, that PSF's License Agreement and PSF's notice of copyright,
|
||||
i.e., "Copyright (c) 2001, 2002, 2003, 2004, 2005, 2006, 2007, 2008, 2009, 2010,
|
||||
2011, 2012, 2013, 2014, 2015, 2016, 2017, 2018, 2019, 2020 Python Software Foundation;
|
||||
All Rights Reserved" are retained in Python alone or in any derivative version
|
||||
prepared by Licensee.
|
||||
|
||||
3. In the event Licensee prepares a derivative work that is based on
|
||||
or incorporates Python or any part thereof, and wants to make
|
||||
the derivative work available to others as provided herein, then
|
||||
Licensee hereby agrees to include in any such work a brief summary of
|
||||
the changes made to Python.
|
||||
|
||||
4. PSF is making Python available to Licensee on an "AS IS"
|
||||
basis. PSF MAKES NO REPRESENTATIONS OR WARRANTIES, EXPRESS OR
|
||||
IMPLIED. BY WAY OF EXAMPLE, BUT NOT LIMITATION, PSF MAKES NO AND
|
||||
DISCLAIMS ANY REPRESENTATION OR WARRANTY OF MERCHANTABILITY OR FITNESS
|
||||
FOR ANY PARTICULAR PURPOSE OR THAT THE USE OF PYTHON WILL NOT
|
||||
INFRINGE ANY THIRD PARTY RIGHTS.
|
||||
|
||||
5. PSF SHALL NOT BE LIABLE TO LICENSEE OR ANY OTHER USERS OF PYTHON
|
||||
FOR ANY INCIDENTAL, SPECIAL, OR CONSEQUENTIAL DAMAGES OR LOSS AS
|
||||
A RESULT OF MODIFYING, DISTRIBUTING, OR OTHERWISE USING PYTHON,
|
||||
OR ANY DERIVATIVE THEREOF, EVEN IF ADVISED OF THE POSSIBILITY THEREOF.
|
||||
|
||||
6. This License Agreement will automatically terminate upon a material
|
||||
breach of its terms and conditions.
|
||||
|
||||
7. Nothing in this License Agreement shall be deemed to create any
|
||||
relationship of agency, partnership, or joint venture between PSF and
|
||||
Licensee. This License Agreement does not grant permission to use PSF
|
||||
trademarks or trade name in a trademark sense to endorse or promote
|
||||
products or services of Licensee, or any third party.
|
||||
|
||||
8. By copying, installing or otherwise using Python, Licensee
|
||||
agrees to be bound by the terms and conditions of this License
|
||||
Agreement.
|
||||
|
||||
|
||||
BEOPEN.COM LICENSE AGREEMENT FOR PYTHON 2.0
|
||||
-------------------------------------------
|
||||
|
||||
BEOPEN PYTHON OPEN SOURCE LICENSE AGREEMENT VERSION 1
|
||||
|
||||
1. This LICENSE AGREEMENT is between BeOpen.com ("BeOpen"), having an
|
||||
office at 160 Saratoga Avenue, Santa Clara, CA 95051, and the
|
||||
Individual or Organization ("Licensee") accessing and otherwise using
|
||||
this software in source or binary form and its associated
|
||||
documentation ("the Software").
|
||||
|
||||
2. Subject to the terms and conditions of this BeOpen Python License
|
||||
Agreement, BeOpen hereby grants Licensee a non-exclusive,
|
||||
royalty-free, world-wide license to reproduce, analyze, test, perform
|
||||
and/or display publicly, prepare derivative works, distribute, and
|
||||
otherwise use the Software alone or in any derivative version,
|
||||
provided, however, that the BeOpen Python License is retained in the
|
||||
Software, alone or in any derivative version prepared by Licensee.
|
||||
|
||||
3. BeOpen is making the Software available to Licensee on an "AS IS"
|
||||
basis. BEOPEN MAKES NO REPRESENTATIONS OR WARRANTIES, EXPRESS OR
|
||||
IMPLIED. BY WAY OF EXAMPLE, BUT NOT LIMITATION, BEOPEN MAKES NO AND
|
||||
DISCLAIMS ANY REPRESENTATION OR WARRANTY OF MERCHANTABILITY OR FITNESS
|
||||
FOR ANY PARTICULAR PURPOSE OR THAT THE USE OF THE SOFTWARE WILL NOT
|
||||
INFRINGE ANY THIRD PARTY RIGHTS.
|
||||
|
||||
4. BEOPEN SHALL NOT BE LIABLE TO LICENSEE OR ANY OTHER USERS OF THE
|
||||
SOFTWARE FOR ANY INCIDENTAL, SPECIAL, OR CONSEQUENTIAL DAMAGES OR LOSS
|
||||
AS A RESULT OF USING, MODIFYING OR DISTRIBUTING THE SOFTWARE, OR ANY
|
||||
DERIVATIVE THEREOF, EVEN IF ADVISED OF THE POSSIBILITY THEREOF.
|
||||
|
||||
5. This License Agreement will automatically terminate upon a material
|
||||
breach of its terms and conditions.
|
||||
|
||||
6. This License Agreement shall be governed by and interpreted in all
|
||||
respects by the law of the State of California, excluding conflict of
|
||||
law provisions. Nothing in this License Agreement shall be deemed to
|
||||
create any relationship of agency, partnership, or joint venture
|
||||
between BeOpen and Licensee. This License Agreement does not grant
|
||||
permission to use BeOpen trademarks or trade names in a trademark
|
||||
sense to endorse or promote products or services of Licensee, or any
|
||||
third party. As an exception, the "BeOpen Python" logos available at
|
||||
http://www.pythonlabs.com/logos.html may be used according to the
|
||||
permissions granted on that web page.
|
||||
|
||||
7. By copying, installing or otherwise using the software, Licensee
|
||||
agrees to be bound by the terms and conditions of this License
|
||||
Agreement.
|
||||
|
||||
|
||||
CNRI LICENSE AGREEMENT FOR PYTHON 1.6.1
|
||||
---------------------------------------
|
||||
|
||||
1. This LICENSE AGREEMENT is between the Corporation for National
|
||||
Research Initiatives, having an office at 1895 Preston White Drive,
|
||||
Reston, VA 20191 ("CNRI"), and the Individual or Organization
|
||||
("Licensee") accessing and otherwise using Python 1.6.1 software in
|
||||
source or binary form and its associated documentation.
|
||||
|
||||
2. Subject to the terms and conditions of this License Agreement, CNRI
|
||||
hereby grants Licensee a nonexclusive, royalty-free, world-wide
|
||||
license to reproduce, analyze, test, perform and/or display publicly,
|
||||
prepare derivative works, distribute, and otherwise use Python 1.6.1
|
||||
alone or in any derivative version, provided, however, that CNRI's
|
||||
License Agreement and CNRI's notice of copyright, i.e., "Copyright (c)
|
||||
1995-2001 Corporation for National Research Initiatives; All Rights
|
||||
Reserved" are retained in Python 1.6.1 alone or in any derivative
|
||||
version prepared by Licensee. Alternately, in lieu of CNRI's License
|
||||
Agreement, Licensee may substitute the following text (omitting the
|
||||
quotes): "Python 1.6.1 is made available subject to the terms and
|
||||
conditions in CNRI's License Agreement. This Agreement together with
|
||||
Python 1.6.1 may be located on the Internet using the following
|
||||
unique, persistent identifier (known as a handle): 1895.22/1013. This
|
||||
Agreement may also be obtained from a proxy server on the Internet
|
||||
using the following URL: http://hdl.handle.net/1895.22/1013".
|
||||
|
||||
3. In the event Licensee prepares a derivative work that is based on
|
||||
or incorporates Python 1.6.1 or any part thereof, and wants to make
|
||||
the derivative work available to others as provided herein, then
|
||||
Licensee hereby agrees to include in any such work a brief summary of
|
||||
the changes made to Python 1.6.1.
|
||||
|
||||
4. CNRI is making Python 1.6.1 available to Licensee on an "AS IS"
|
||||
basis. CNRI MAKES NO REPRESENTATIONS OR WARRANTIES, EXPRESS OR
|
||||
IMPLIED. BY WAY OF EXAMPLE, BUT NOT LIMITATION, CNRI MAKES NO AND
|
||||
DISCLAIMS ANY REPRESENTATION OR WARRANTY OF MERCHANTABILITY OR FITNESS
|
||||
FOR ANY PARTICULAR PURPOSE OR THAT THE USE OF PYTHON 1.6.1 WILL NOT
|
||||
INFRINGE ANY THIRD PARTY RIGHTS.
|
||||
|
||||
5. CNRI SHALL NOT BE LIABLE TO LICENSEE OR ANY OTHER USERS OF PYTHON
|
||||
1.6.1 FOR ANY INCIDENTAL, SPECIAL, OR CONSEQUENTIAL DAMAGES OR LOSS AS
|
||||
A RESULT OF MODIFYING, DISTRIBUTING, OR OTHERWISE USING PYTHON 1.6.1,
|
||||
OR ANY DERIVATIVE THEREOF, EVEN IF ADVISED OF THE POSSIBILITY THEREOF.
|
||||
|
||||
6. This License Agreement will automatically terminate upon a material
|
||||
breach of its terms and conditions.
|
||||
|
||||
7. This License Agreement shall be governed by the federal
|
||||
intellectual property law of the United States, including without
|
||||
limitation the federal copyright law, and, to the extent such
|
||||
U.S. federal law does not apply, by the law of the Commonwealth of
|
||||
Virginia, excluding Virginia's conflict of law provisions.
|
||||
Notwithstanding the foregoing, with regard to derivative works based
|
||||
on Python 1.6.1 that incorporate non-separable material that was
|
||||
previously distributed under the GNU General Public License (GPL), the
|
||||
law of the Commonwealth of Virginia shall govern this License
|
||||
Agreement only as to issues arising under or with respect to
|
||||
Paragraphs 4, 5, and 7 of this License Agreement. Nothing in this
|
||||
License Agreement shall be deemed to create any relationship of
|
||||
agency, partnership, or joint venture between CNRI and Licensee. This
|
||||
License Agreement does not grant permission to use CNRI trademarks or
|
||||
trade name in a trademark sense to endorse or promote products or
|
||||
services of Licensee, or any third party.
|
||||
|
||||
8. By clicking on the "ACCEPT" button where indicated, or by copying,
|
||||
installing or otherwise using Python 1.6.1, Licensee agrees to be
|
||||
bound by the terms and conditions of this License Agreement.
|
||||
|
||||
ACCEPT
|
||||
|
||||
|
||||
CWI LICENSE AGREEMENT FOR PYTHON 0.9.0 THROUGH 1.2
|
||||
--------------------------------------------------
|
||||
|
||||
Copyright (c) 1991 - 1995, Stichting Mathematisch Centrum Amsterdam,
|
||||
The Netherlands. All rights reserved.
|
||||
|
||||
Permission to use, copy, modify, and distribute this software and its
|
||||
documentation for any purpose and without fee is hereby granted,
|
||||
provided that the above copyright notice appear in all copies and that
|
||||
both that copyright notice and this permission notice appear in
|
||||
supporting documentation, and that the name of Stichting Mathematisch
|
||||
Centrum or CWI not be used in advertising or publicity pertaining to
|
||||
distribution of the software without specific, written prior
|
||||
permission.
|
||||
|
||||
STICHTING MATHEMATISCH CENTRUM DISCLAIMS ALL WARRANTIES WITH REGARD TO
|
||||
THIS SOFTWARE, INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND
|
||||
FITNESS, IN NO EVENT SHALL STICHTING MATHEMATISCH CENTRUM BE LIABLE
|
||||
FOR ANY SPECIAL, INDIRECT OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
|
||||
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
|
||||
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT
|
||||
OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
|
||||
============================================================
|
||||
entities@4.5.0
|
||||
(git://github.com/fb55/entities.git)
|
||||
|
||||
Copyright (c) Felix Böhm
|
||||
All rights reserved.
|
||||
|
||||
Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
|
||||
|
||||
Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
|
||||
|
||||
Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
|
||||
|
||||
THIS IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS,
|
||||
EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
|
||||
|
||||
============================================================
|
||||
linkify-it@5.0.0
|
||||
(No repository found)
|
||||
|
||||
Copyright (c) 2015 Vitaly Puzrin.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person
|
||||
obtaining a copy of this software and associated documentation
|
||||
files (the "Software"), to deal in the Software without
|
||||
restriction, including without limitation the rights to use,
|
||||
copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the
|
||||
Software is furnished to do so, subject to the following
|
||||
conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be
|
||||
included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
|
||||
OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
||||
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
|
||||
HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
|
||||
OTHER DEALINGS IN THE SOFTWARE.
|
||||
|
||||
|
||||
============================================================
|
||||
uc.micro@2.1.0
|
||||
(No repository found)
|
||||
|
||||
Copyright Mathias Bynens <https://mathiasbynens.be/>
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of this software and associated documentation files (the
|
||||
"Software"), to deal in the Software without restriction, including
|
||||
without limitation the rights to use, copy, modify, merge, publish,
|
||||
distribute, sublicense, and/or sell copies of the Software, and to
|
||||
permit persons to whom the Software is furnished to do so, subject to
|
||||
the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be
|
||||
included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
||||
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
||||
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
||||
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
|
||||
|
||||
============================================================
|
||||
mdurl@2.0.0
|
||||
(No repository found)
|
||||
|
||||
Copyright (c) 2015 Vitaly Puzrin, Alex Kocharin.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person
|
||||
obtaining a copy of this software and associated documentation
|
||||
files (the "Software"), to deal in the Software without
|
||||
restriction, including without limitation the rights to use,
|
||||
copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the
|
||||
Software is furnished to do so, subject to the following
|
||||
conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be
|
||||
included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
|
||||
OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
||||
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
|
||||
HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
|
||||
OTHER DEALINGS IN THE SOFTWARE.
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
|
||||
.parse() is based on Joyent's node.js `url` code:
|
||||
|
||||
Copyright Joyent, Inc. and other Node contributors. All rights reserved.
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to
|
||||
deal in the Software without restriction, including without limitation the
|
||||
rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
|
||||
sell copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
|
||||
IN THE SOFTWARE.
|
||||
|
||||
|
||||
============================================================
|
||||
punycode.js@2.3.1
|
||||
(https://github.com/mathiasbynens/punycode.js.git)
|
||||
|
||||
Copyright Mathias Bynens <https://mathiasbynens.be/>
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of this software and associated documentation files (the
|
||||
"Software"), to deal in the Software without restriction, including
|
||||
without limitation the rights to use, copy, modify, merge, publish,
|
||||
distribute, sublicense, and/or sell copies of the Software, and to
|
||||
permit persons to whom the Software is furnished to do so, subject to
|
||||
the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be
|
||||
included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
||||
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
||||
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
||||
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
|
||||
|
||||
============================================================
|
||||
react@19.1.0
|
||||
(https://github.com/facebook/react.git)
|
||||
|
||||
MIT License
|
||||
|
||||
Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
|
||||
============================================================
|
||||
react-dom@19.1.0
|
||||
(https://github.com/facebook/react.git)
|
||||
|
||||
MIT License
|
||||
|
||||
Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
|
||||
============================================================
|
||||
scheduler@0.26.0
|
||||
(https://github.com/facebook/react.git)
|
||||
|
||||
MIT License
|
||||
|
||||
Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
|
||||
|
||||
@@ -19,6 +19,63 @@ To use this extension, you'll need:
|
||||
- VS Code version 1.101.0 or newer
|
||||
- Qwen Code (installed separately) running within the VS Code integrated terminal
|
||||
|
||||
# Development and Debugging
|
||||
|
||||
To debug and develop this extension locally:
|
||||
|
||||
1. **Clone the repository**
|
||||
|
||||
```bash
|
||||
git clone https://github.com/QwenLM/qwen-code.git
|
||||
cd qwen-code
|
||||
```
|
||||
|
||||
2. **Install dependencies**
|
||||
|
||||
```bash
|
||||
npm install
|
||||
# or if using pnpm
|
||||
pnpm install
|
||||
```
|
||||
|
||||
3. **Start debugging**
|
||||
|
||||
```bash
|
||||
code . # Open the project root in VS Code
|
||||
```
|
||||
- Open the `packages/vscode-ide-companion/src/extension.ts` file
|
||||
- Open Debug panel (`Ctrl+Shift+D` or `Cmd+Shift+D`)
|
||||
- Select **"Launch Companion VS Code Extension"** from the debug dropdown
|
||||
- Press `F5` to launch Extension Development Host
|
||||
|
||||
4. **Make changes and reload**
|
||||
- Edit the source code in the original VS Code window
|
||||
- To see your changes, reload the Extension Development Host window by:
|
||||
- Pressing `Ctrl+R` (Windows/Linux) or `Cmd+R` (macOS)
|
||||
- Or clicking the "Reload" button in the debug toolbar
|
||||
|
||||
5. **View logs and debug output**
|
||||
- Open the Debug Console in the original VS Code window to see extension logs
|
||||
- In the Extension Development Host window, open Developer Tools with `Help > Toggle Developer Tools` to see webview logs
|
||||
|
||||
## Build for Production
|
||||
|
||||
To build the extension for distribution:
|
||||
|
||||
```bash
|
||||
npm run compile
|
||||
# or
|
||||
pnpm run compile
|
||||
```
|
||||
|
||||
To package the extension as a VSIX file:
|
||||
|
||||
```bash
|
||||
npx vsce package
|
||||
# or
|
||||
pnpm vsce package
|
||||
```
|
||||
|
||||
# Terms of Service and Privacy Notice
|
||||
|
||||
By installing this extension, you agree to the [Terms of Service](https://github.com/QwenLM/qwen-code/blob/main/docs/tos-privacy.md).
|
||||
|
||||
@@ -31,8 +31,69 @@ const esbuildProblemMatcherPlugin = {
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* @type {import('esbuild').Plugin}
|
||||
*/
|
||||
const cssInjectPlugin = {
|
||||
name: 'css-inject',
|
||||
setup(build) {
|
||||
// Handle CSS files
|
||||
build.onLoad({ filter: /\.css$/ }, async (args) => {
|
||||
const fs = await import('fs');
|
||||
const postcss = (await import('postcss')).default;
|
||||
const tailwindcss = (await import('tailwindcss')).default;
|
||||
const autoprefixer = (await import('autoprefixer')).default;
|
||||
|
||||
let css = await fs.promises.readFile(args.path, 'utf8');
|
||||
|
||||
// For styles.css, we need to resolve @import statements
|
||||
if (args.path.endsWith('styles.css')) {
|
||||
// Read all imported CSS files and inline them
|
||||
const importRegex = /@import\s+'([^']+)';/g;
|
||||
let match;
|
||||
const basePath = args.path.substring(0, args.path.lastIndexOf('/'));
|
||||
while ((match = importRegex.exec(css)) !== null) {
|
||||
const importPath = match[1];
|
||||
// Resolve relative paths correctly
|
||||
let fullPath;
|
||||
if (importPath.startsWith('./')) {
|
||||
fullPath = basePath + importPath.substring(1);
|
||||
} else if (importPath.startsWith('../')) {
|
||||
fullPath = basePath + '/' + importPath;
|
||||
} else {
|
||||
fullPath = basePath + '/' + importPath;
|
||||
}
|
||||
|
||||
try {
|
||||
const importedCss = await fs.promises.readFile(fullPath, 'utf8');
|
||||
css = css.replace(match[0], importedCss);
|
||||
} catch (err) {
|
||||
console.warn(`Could not import ${fullPath}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Process with PostCSS (Tailwind + Autoprefixer)
|
||||
const result = await postcss([tailwindcss, autoprefixer]).process(css, {
|
||||
from: args.path,
|
||||
to: args.path,
|
||||
});
|
||||
|
||||
return {
|
||||
contents: `
|
||||
const style = document.createElement('style');
|
||||
style.textContent = ${JSON.stringify(result.css)};
|
||||
document.head.appendChild(style);
|
||||
`,
|
||||
loader: 'js',
|
||||
};
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
async function main() {
|
||||
const ctx = await esbuild.context({
|
||||
// Build extension
|
||||
const extensionCtx = await esbuild.context({
|
||||
entryPoints: ['src/extension.ts'],
|
||||
bundle: true,
|
||||
format: 'cjs',
|
||||
@@ -55,11 +116,30 @@ async function main() {
|
||||
],
|
||||
loader: { '.node': 'file' },
|
||||
});
|
||||
|
||||
// Build webview
|
||||
const webviewCtx = await esbuild.context({
|
||||
entryPoints: ['src/webview/index.tsx'],
|
||||
bundle: true,
|
||||
format: 'iife',
|
||||
minify: production,
|
||||
sourcemap: !production,
|
||||
sourcesContent: false,
|
||||
platform: 'browser',
|
||||
outfile: 'dist/webview.js',
|
||||
logLevel: 'silent',
|
||||
plugins: [cssInjectPlugin, esbuildProblemMatcherPlugin],
|
||||
jsx: 'automatic', // Use new JSX transform (React 17+)
|
||||
define: {
|
||||
'process.env.NODE_ENV': production ? '"production"' : '"development"',
|
||||
},
|
||||
});
|
||||
|
||||
if (watch) {
|
||||
await ctx.watch();
|
||||
await Promise.all([extensionCtx.watch(), webviewCtx.watch()]);
|
||||
} else {
|
||||
await ctx.rebuild();
|
||||
await ctx.dispose();
|
||||
await Promise.all([extensionCtx.rebuild(), webviewCtx.rebuild()]);
|
||||
await Promise.all([extensionCtx.dispose(), webviewCtx.dispose()]);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,20 +6,44 @@
|
||||
|
||||
import typescriptEslint from '@typescript-eslint/eslint-plugin';
|
||||
import tsParser from '@typescript-eslint/parser';
|
||||
import reactHooks from 'eslint-plugin-react-hooks';
|
||||
import importPlugin from 'eslint-plugin-import';
|
||||
|
||||
export default [
|
||||
{
|
||||
files: ['**/*.ts'],
|
||||
files: ['**/*.ts', '**/*.tsx'],
|
||||
},
|
||||
{
|
||||
files: ['**/*.js', '**/*.cjs', '**/*.mjs'],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2022,
|
||||
sourceType: 'commonjs',
|
||||
globals: {
|
||||
module: 'readonly',
|
||||
require: 'readonly',
|
||||
__dirname: 'readonly',
|
||||
__filename: 'readonly',
|
||||
process: 'readonly',
|
||||
console: 'readonly',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
plugins: {
|
||||
'@typescript-eslint': typescriptEslint,
|
||||
'react-hooks': reactHooks,
|
||||
import: importPlugin,
|
||||
},
|
||||
|
||||
languageOptions: {
|
||||
parser: tsParser,
|
||||
ecmaVersion: 2022,
|
||||
sourceType: 'module',
|
||||
parserOptions: {
|
||||
ecmaFeatures: {
|
||||
jsx: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
rules: {
|
||||
@@ -30,6 +54,17 @@ export default [
|
||||
format: ['camelCase', 'PascalCase'],
|
||||
},
|
||||
],
|
||||
'react-hooks/rules-of-hooks': 'error',
|
||||
'react-hooks/exhaustive-deps': 'error',
|
||||
// Restrict deep imports but allow known-safe exceptions used by the webview
|
||||
// - react-dom/client: required for React 18's createRoot API
|
||||
// - ./styles/**: local CSS modules loaded by the webview
|
||||
'import/no-internal-modules': [
|
||||
'error',
|
||||
{
|
||||
allow: ['react-dom/client', './styles/**'],
|
||||
},
|
||||
],
|
||||
|
||||
curly: 'warn',
|
||||
eqeqeq: 'warn',
|
||||
|
||||
@@ -54,6 +54,15 @@
|
||||
{
|
||||
"command": "qwen-code.showNotices",
|
||||
"title": "Qwen Code: View Third-Party Notices"
|
||||
},
|
||||
{
|
||||
"command": "qwen-code.openChat",
|
||||
"title": "Qwen Code: Open",
|
||||
"icon": "./assets/icon.png"
|
||||
},
|
||||
{
|
||||
"command": "qwen-code.login",
|
||||
"title": "Qwen Code: Login"
|
||||
}
|
||||
],
|
||||
"menus": {
|
||||
@@ -65,6 +74,10 @@
|
||||
{
|
||||
"command": "qwen.diff.cancel",
|
||||
"when": "qwen.diff.isVisible"
|
||||
},
|
||||
{
|
||||
"command": "qwen-code.login",
|
||||
"when": "false"
|
||||
}
|
||||
],
|
||||
"editor/title": [
|
||||
@@ -77,6 +90,10 @@
|
||||
"command": "qwen.diff.cancel",
|
||||
"when": "qwen.diff.isVisible",
|
||||
"group": "navigation"
|
||||
},
|
||||
{
|
||||
"command": "qwen-code.openChat",
|
||||
"group": "navigation"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -115,21 +132,33 @@
|
||||
"devDependencies": {
|
||||
"@types/cors": "^2.8.19",
|
||||
"@types/express": "^5.0.3",
|
||||
"@types/markdown-it": "^14.1.2",
|
||||
"@types/node": "20.x",
|
||||
"@types/react": "^19.1.8",
|
||||
"@types/react-dom": "^19.1.6",
|
||||
"@types/semver": "^7.7.1",
|
||||
"@types/vscode": "^1.99.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.31.1",
|
||||
"@typescript-eslint/parser": "^8.31.1",
|
||||
"@vscode/vsce": "^3.6.0",
|
||||
"autoprefixer": "^10.4.22",
|
||||
"esbuild": "^0.25.3",
|
||||
"eslint": "^9.25.1",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"npm-run-all2": "^8.0.2",
|
||||
"postcss": "^8.5.6",
|
||||
"tailwindcss": "^3.4.18",
|
||||
"typescript": "^5.8.3",
|
||||
"vitest": "^3.2.4"
|
||||
},
|
||||
"dependencies": {
|
||||
"semver": "^7.7.2",
|
||||
"@modelcontextprotocol/sdk": "^1.15.1",
|
||||
"cors": "^2.8.5",
|
||||
"express": "^5.1.0",
|
||||
"markdown-it": "^14.1.0",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"zod": "^3.25.76"
|
||||
}
|
||||
}
|
||||
|
||||
13
packages/vscode-ide-companion/postcss.config.js
Normal file
13
packages/vscode-ide-companion/postcss.config.js
Normal file
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/* eslint-disable no-undef */
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
58
packages/vscode-ide-companion/src/cli/cliContextManager.ts
Normal file
58
packages/vscode-ide-companion/src/cli/cliContextManager.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { CliFeatureFlags, CliVersionInfo } from './cliVersionManager.js';
|
||||
|
||||
export class CliContextManager {
|
||||
private static instance: CliContextManager;
|
||||
private currentVersionInfo: CliVersionInfo | null = null;
|
||||
|
||||
private constructor() {}
|
||||
|
||||
/**
|
||||
* Get singleton instance
|
||||
*/
|
||||
static getInstance(): CliContextManager {
|
||||
if (!CliContextManager.instance) {
|
||||
CliContextManager.instance = new CliContextManager();
|
||||
}
|
||||
return CliContextManager.instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set current CLI version information
|
||||
*
|
||||
* @param versionInfo - CLI version information
|
||||
*/
|
||||
setCurrentVersionInfo(versionInfo: CliVersionInfo): void {
|
||||
this.currentVersionInfo = versionInfo;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current CLI feature flags
|
||||
*
|
||||
* @returns Current CLI feature flags or default flags if not set
|
||||
*/
|
||||
getCurrentFeatures(): CliFeatureFlags {
|
||||
if (this.currentVersionInfo) {
|
||||
return this.currentVersionInfo.features;
|
||||
}
|
||||
|
||||
// Return default feature flags (all disabled)
|
||||
return {
|
||||
supportsSessionList: false,
|
||||
supportsSessionLoad: false,
|
||||
};
|
||||
}
|
||||
|
||||
supportsSessionList(): boolean {
|
||||
return this.getCurrentFeatures().supportsSessionList;
|
||||
}
|
||||
|
||||
supportsSessionLoad(): boolean {
|
||||
return this.getCurrentFeatures().supportsSessionLoad;
|
||||
}
|
||||
}
|
||||
215
packages/vscode-ide-companion/src/cli/cliDetector.ts
Normal file
215
packages/vscode-ide-companion/src/cli/cliDetector.ts
Normal file
@@ -0,0 +1,215 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { exec } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
export interface CliDetectionResult {
|
||||
isInstalled: boolean;
|
||||
cliPath?: string;
|
||||
version?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detects if Qwen Code CLI is installed and accessible
|
||||
*/
|
||||
export class CliDetector {
|
||||
private static cachedResult: CliDetectionResult | null = null;
|
||||
private static lastCheckTime: number = 0;
|
||||
private static readonly CACHE_DURATION_MS = 30000; // 30 seconds
|
||||
|
||||
/**
|
||||
* Checks if the Qwen Code CLI is installed
|
||||
* @param forceRefresh - Force a new check, ignoring cache
|
||||
* @returns Detection result with installation status and details
|
||||
*/
|
||||
static async detectQwenCli(
|
||||
forceRefresh = false,
|
||||
): Promise<CliDetectionResult> {
|
||||
const now = Date.now();
|
||||
|
||||
// Return cached result if available and not expired
|
||||
if (
|
||||
!forceRefresh &&
|
||||
this.cachedResult &&
|
||||
now - this.lastCheckTime < this.CACHE_DURATION_MS
|
||||
) {
|
||||
console.log('[CliDetector] Returning cached result');
|
||||
return this.cachedResult;
|
||||
}
|
||||
|
||||
console.log(
|
||||
'[CliDetector] Starting CLI detection, current PATH:',
|
||||
process.env.PATH,
|
||||
);
|
||||
|
||||
try {
|
||||
const isWindows = process.platform === 'win32';
|
||||
const whichCommand = isWindows ? 'where' : 'which';
|
||||
|
||||
// Check if qwen command exists
|
||||
try {
|
||||
// Use NVM environment for consistent detection
|
||||
// Fallback chain: default alias -> node alias -> current version
|
||||
const detectionCommand =
|
||||
process.platform === 'win32'
|
||||
? `${whichCommand} qwen`
|
||||
: 'source ~/.nvm/nvm.sh 2>/dev/null && (nvm use default 2>/dev/null || nvm use node 2>/dev/null || nvm use 2>/dev/null); which qwen';
|
||||
|
||||
console.log(
|
||||
'[CliDetector] Detecting CLI with command:',
|
||||
detectionCommand,
|
||||
);
|
||||
|
||||
const { stdout } = await execAsync(detectionCommand, {
|
||||
timeout: 5000,
|
||||
shell: '/bin/bash',
|
||||
});
|
||||
// The output may contain multiple lines, with NVM activation messages
|
||||
// We want the last line which should be the actual path
|
||||
const lines = stdout
|
||||
.trim()
|
||||
.split('\n')
|
||||
.filter((line) => line.trim());
|
||||
const cliPath = lines[lines.length - 1];
|
||||
|
||||
console.log('[CliDetector] Found CLI at:', cliPath);
|
||||
|
||||
// Try to get version
|
||||
let version: string | undefined;
|
||||
try {
|
||||
// Use NVM environment for version check
|
||||
// Fallback chain: default alias -> node alias -> current version
|
||||
// Also ensure we use the correct Node.js version that matches the CLI installation
|
||||
const versionCommand =
|
||||
process.platform === 'win32'
|
||||
? 'qwen --version'
|
||||
: 'source ~/.nvm/nvm.sh 2>/dev/null && (nvm use default 2>/dev/null || nvm use node 2>/dev/null || nvm use 2>/dev/null); qwen --version';
|
||||
|
||||
console.log(
|
||||
'[CliDetector] Getting version with command:',
|
||||
versionCommand,
|
||||
);
|
||||
|
||||
const { stdout: versionOutput } = await execAsync(versionCommand, {
|
||||
timeout: 5000,
|
||||
shell: '/bin/bash',
|
||||
});
|
||||
// The output may contain multiple lines, with NVM activation messages
|
||||
// We want the last line which should be the actual version
|
||||
const versionLines = versionOutput
|
||||
.trim()
|
||||
.split('\n')
|
||||
.filter((line) => line.trim());
|
||||
version = versionLines[versionLines.length - 1];
|
||||
console.log('[CliDetector] CLI version:', version);
|
||||
} catch (versionError) {
|
||||
console.log('[CliDetector] Failed to get CLI version:', versionError);
|
||||
// Version check failed, but CLI is installed
|
||||
}
|
||||
|
||||
this.cachedResult = {
|
||||
isInstalled: true,
|
||||
cliPath,
|
||||
version,
|
||||
};
|
||||
this.lastCheckTime = now;
|
||||
return this.cachedResult;
|
||||
} catch (detectionError) {
|
||||
console.log('[CliDetector] CLI not found, error:', detectionError);
|
||||
// CLI not found
|
||||
let error = `Qwen Code CLI not found in PATH. Please install it using: npm install -g @qwen-code/qwen-code@latest`;
|
||||
|
||||
// Provide specific guidance for permission errors
|
||||
if (detectionError instanceof Error) {
|
||||
const errorMessage = detectionError.message;
|
||||
if (
|
||||
errorMessage.includes('EACCES') ||
|
||||
errorMessage.includes('Permission denied')
|
||||
) {
|
||||
error += `\n\nThis may be due to permission issues. Possible solutions:
|
||||
\n1. Reinstall the CLI without sudo: npm install -g @qwen-code/qwen-code@latest
|
||||
\n2. If previously installed with sudo, fix ownership: sudo chown -R $(whoami) $(npm config get prefix)/lib/node_modules/@qwen-code/qwen-code
|
||||
\n3. Use nvm for Node.js version management to avoid permission issues
|
||||
\n4. Check your PATH environment variable includes npm's global bin directory`;
|
||||
}
|
||||
}
|
||||
|
||||
this.cachedResult = {
|
||||
isInstalled: false,
|
||||
error,
|
||||
};
|
||||
this.lastCheckTime = now;
|
||||
return this.cachedResult;
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('[CliDetector] General detection error:', error);
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
|
||||
let userFriendlyError = `Failed to detect Qwen Code CLI: ${errorMessage}`;
|
||||
|
||||
// Provide specific guidance for permission errors
|
||||
if (
|
||||
errorMessage.includes('EACCES') ||
|
||||
errorMessage.includes('Permission denied')
|
||||
) {
|
||||
userFriendlyError += `\n\nThis may be due to permission issues. Possible solutions:
|
||||
\n1. Reinstall the CLI without sudo: npm install -g @qwen-code/qwen-code@latest
|
||||
\n2. If previously installed with sudo, fix ownership: sudo chown -R $(whoami) $(npm config get prefix)/lib/node_modules/@qwen-code/qwen-code
|
||||
\n3. Use nvm for Node.js version management to avoid permission issues
|
||||
\n4. Check your PATH environment variable includes npm's global bin directory`;
|
||||
}
|
||||
|
||||
this.cachedResult = {
|
||||
isInstalled: false,
|
||||
error: userFriendlyError,
|
||||
};
|
||||
this.lastCheckTime = now;
|
||||
return this.cachedResult;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the cached detection result
|
||||
*/
|
||||
static clearCache(): void {
|
||||
this.cachedResult = null;
|
||||
this.lastCheckTime = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets installation instructions based on the platform
|
||||
*/
|
||||
static getInstallationInstructions(): {
|
||||
title: string;
|
||||
steps: string[];
|
||||
documentationUrl: string;
|
||||
} {
|
||||
return {
|
||||
title: 'Qwen Code CLI is not installed',
|
||||
steps: [
|
||||
'Install via npm:',
|
||||
' npm install -g @qwen-code/qwen-code@latest',
|
||||
'',
|
||||
'If you are using nvm (automatically handled by the plugin):',
|
||||
' The plugin will automatically use your default nvm version',
|
||||
'',
|
||||
'Or install from source:',
|
||||
' git clone https://github.com/QwenLM/qwen-code.git',
|
||||
' cd qwen-code',
|
||||
' npm install',
|
||||
' npm install -g .',
|
||||
'',
|
||||
'After installation, reload VS Code or restart the extension.',
|
||||
],
|
||||
documentationUrl: 'https://github.com/QwenLM/qwen-code#installation',
|
||||
};
|
||||
}
|
||||
}
|
||||
225
packages/vscode-ide-companion/src/cli/cliInstaller.ts
Normal file
225
packages/vscode-ide-companion/src/cli/cliInstaller.ts
Normal file
@@ -0,0 +1,225 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import * as vscode from 'vscode';
|
||||
import { CliDetector } from './cliDetector.js';
|
||||
|
||||
/**
|
||||
* CLI Detection and Installation Handler
|
||||
* Responsible for detecting, installing, and prompting for Qwen CLI
|
||||
*/
|
||||
export class CliInstaller {
|
||||
/**
|
||||
* Check CLI installation status and send results to WebView
|
||||
* @param sendToWebView Callback function to send messages to WebView
|
||||
*/
|
||||
static async checkInstallation(
|
||||
sendToWebView: (message: unknown) => void,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const result = await CliDetector.detectQwenCli();
|
||||
|
||||
sendToWebView({
|
||||
type: 'cliDetectionResult',
|
||||
data: {
|
||||
isInstalled: result.isInstalled,
|
||||
cliPath: result.cliPath,
|
||||
version: result.version,
|
||||
error: result.error,
|
||||
installInstructions: result.isInstalled
|
||||
? undefined
|
||||
: CliDetector.getInstallationInstructions(),
|
||||
},
|
||||
});
|
||||
|
||||
if (!result.isInstalled) {
|
||||
console.log('[CliInstaller] Qwen CLI not detected:', result.error);
|
||||
} else {
|
||||
console.log(
|
||||
'[CliInstaller] Qwen CLI detected:',
|
||||
result.cliPath,
|
||||
result.version,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[CliInstaller] CLI detection error:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Prompt user to install CLI
|
||||
* Display warning message with installation options
|
||||
*/
|
||||
static async promptInstallation(): Promise<void> {
|
||||
const selection = await vscode.window.showWarningMessage(
|
||||
'Qwen Code CLI is not installed. You can browse conversation history, but cannot send new messages.',
|
||||
'Install Now',
|
||||
'View Documentation',
|
||||
'Remind Me Later',
|
||||
);
|
||||
|
||||
if (selection === 'Install Now') {
|
||||
await this.install();
|
||||
} else if (selection === 'View Documentation') {
|
||||
vscode.env.openExternal(
|
||||
vscode.Uri.parse('https://github.com/QwenLM/qwen-code#installation'),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Install Qwen CLI
|
||||
* Install global CLI package via npm
|
||||
*/
|
||||
static async install(): Promise<void> {
|
||||
try {
|
||||
// Show progress notification
|
||||
await vscode.window.withProgress(
|
||||
{
|
||||
location: vscode.ProgressLocation.Notification,
|
||||
title: 'Installing Qwen Code CLI',
|
||||
cancellable: false,
|
||||
},
|
||||
async (progress) => {
|
||||
progress.report({
|
||||
message: 'Running: npm install -g @qwen-code/qwen-code@latest',
|
||||
});
|
||||
|
||||
const { exec } = await import('child_process');
|
||||
const { promisify } = await import('util');
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
try {
|
||||
// Use NVM environment to ensure we get the same Node.js version
|
||||
// as when they run 'node -v' in terminal
|
||||
// Fallback chain: default alias -> node alias -> current version
|
||||
const installCommand =
|
||||
process.platform === 'win32'
|
||||
? 'npm install -g @qwen-code/qwen-code@latest'
|
||||
: 'source ~/.nvm/nvm.sh 2>/dev/null && (nvm use default 2>/dev/null || nvm use node 2>/dev/null || nvm use 2>/dev/null); npm install -g @qwen-code/qwen-code@latest';
|
||||
|
||||
console.log(
|
||||
'[CliInstaller] Installing with command:',
|
||||
installCommand,
|
||||
);
|
||||
console.log(
|
||||
'[CliInstaller] Current process PATH:',
|
||||
process.env['PATH'],
|
||||
);
|
||||
|
||||
// Also log Node.js version being used by VS Code
|
||||
console.log(
|
||||
'[CliInstaller] VS Code Node.js version:',
|
||||
process.version,
|
||||
);
|
||||
console.log(
|
||||
'[CliInstaller] VS Code Node.js execPath:',
|
||||
process.execPath,
|
||||
);
|
||||
|
||||
const { stdout, stderr } = await execAsync(
|
||||
installCommand,
|
||||
{
|
||||
timeout: 120000,
|
||||
shell: '/bin/bash',
|
||||
}, // 2 minutes timeout
|
||||
);
|
||||
|
||||
console.log('[CliInstaller] Installation output:', stdout);
|
||||
if (stderr) {
|
||||
console.warn('[CliInstaller] Installation stderr:', stderr);
|
||||
}
|
||||
|
||||
// Clear cache and recheck
|
||||
CliDetector.clearCache();
|
||||
const detection = await CliDetector.detectQwenCli();
|
||||
|
||||
if (detection.isInstalled) {
|
||||
vscode.window
|
||||
.showInformationMessage(
|
||||
`✅ Qwen Code CLI installed successfully! Version: ${detection.version}`,
|
||||
'Reload Window',
|
||||
)
|
||||
.then((selection) => {
|
||||
if (selection === 'Reload Window') {
|
||||
vscode.commands.executeCommand(
|
||||
'workbench.action.reloadWindow',
|
||||
);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
throw new Error(
|
||||
'Installation completed but CLI still not detected',
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
console.error('[CliInstaller] Installation failed:', errorMessage);
|
||||
console.error('[CliInstaller] Error stack:', error);
|
||||
|
||||
// Provide specific guidance for permission errors
|
||||
let userFriendlyMessage = `Failed to install Qwen Code CLI: ${errorMessage}`;
|
||||
|
||||
if (
|
||||
errorMessage.includes('EACCES') ||
|
||||
errorMessage.includes('Permission denied')
|
||||
) {
|
||||
userFriendlyMessage += `\n\nThis is likely due to permission issues. Possible solutions:
|
||||
\n1. Reinstall without sudo: npm install -g @qwen-code/qwen-code@latest
|
||||
\n2. Fix npm permissions: sudo chown -R $(whoami) $(npm config get prefix)/{lib/node_modules,bin,share}
|
||||
\n3. Use nvm for Node.js version management to avoid permission issues
|
||||
\n4. Configure npm to use a different directory: npm config set prefix ~/.npm-global`;
|
||||
}
|
||||
|
||||
vscode.window
|
||||
.showErrorMessage(
|
||||
userFriendlyMessage,
|
||||
'Try Manual Installation',
|
||||
'View Documentation',
|
||||
)
|
||||
.then((selection) => {
|
||||
if (selection === 'Try Manual Installation') {
|
||||
const terminal = vscode.window.createTerminal(
|
||||
'Qwen Code Installation',
|
||||
);
|
||||
terminal.show();
|
||||
|
||||
// Provide different installation commands based on error type
|
||||
if (
|
||||
errorMessage.includes('EACCES') ||
|
||||
errorMessage.includes('Permission denied')
|
||||
) {
|
||||
terminal.sendText('# Try installing without sudo:');
|
||||
terminal.sendText(
|
||||
'npm install -g @qwen-code/qwen-code@latest',
|
||||
);
|
||||
terminal.sendText('');
|
||||
terminal.sendText('# Or fix npm permissions:');
|
||||
terminal.sendText(
|
||||
'sudo chown -R $(whoami) $(npm config get prefix)/{lib/node_modules,bin,share}',
|
||||
);
|
||||
} else {
|
||||
terminal.sendText(
|
||||
'npm install -g @qwen-code/qwen-code@latest',
|
||||
);
|
||||
}
|
||||
} else if (selection === 'View Documentation') {
|
||||
vscode.env.openExternal(
|
||||
vscode.Uri.parse(
|
||||
'https://github.com/QwenLM/qwen-code#installation',
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('[CliInstaller] Install CLI error:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
128
packages/vscode-ide-companion/src/cli/cliPathDetector.ts
Normal file
128
packages/vscode-ide-companion/src/cli/cliPathDetector.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { statSync } from 'fs';
|
||||
|
||||
export interface CliPathDetectionResult {
|
||||
path: string | null;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine the correct Node.js executable path for a given CLI installation
|
||||
* Handles various Node.js version managers (nvm, n, manual installations)
|
||||
*
|
||||
* @param cliPath - Path to the CLI executable
|
||||
* @returns Path to the Node.js executable, or null if not found
|
||||
*/
|
||||
export function determineNodePathForCli(
|
||||
cliPath: string,
|
||||
): CliPathDetectionResult {
|
||||
// Common patterns for Node.js installations
|
||||
const nodePathPatterns = [
|
||||
// NVM pattern: /Users/user/.nvm/versions/node/vXX.XX.X/bin/qwen -> /Users/user/.nvm/versions/node/vXX.XX.X/bin/node
|
||||
cliPath.replace(/\/bin\/qwen$/, '/bin/node'),
|
||||
|
||||
// N pattern: /Users/user/n/bin/qwen -> /Users/user/n/bin/node
|
||||
cliPath.replace(/\/bin\/qwen$/, '/bin/node'),
|
||||
|
||||
// Manual installation pattern: /usr/local/bin/qwen -> /usr/local/bin/node
|
||||
cliPath.replace(/\/qwen$/, '/node'),
|
||||
|
||||
// Alternative pattern: /opt/nodejs/bin/qwen -> /opt/nodejs/bin/node
|
||||
cliPath.replace(/\/bin\/qwen$/, '/bin/node'),
|
||||
];
|
||||
|
||||
// Check each pattern
|
||||
for (const nodePath of nodePathPatterns) {
|
||||
try {
|
||||
const stats = statSync(nodePath);
|
||||
if (stats.isFile()) {
|
||||
// Verify it's executable
|
||||
if (stats.mode & 0o111) {
|
||||
console.log(`[CLI] Found Node.js executable for CLI at: ${nodePath}`);
|
||||
return { path: nodePath };
|
||||
} else {
|
||||
console.log(`[CLI] Node.js found at ${nodePath} but not executable`);
|
||||
return {
|
||||
path: null,
|
||||
error: `Node.js found at ${nodePath} but not executable. You may need to fix file permissions or reinstall the CLI.`,
|
||||
};
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Differentiate between error types
|
||||
if (error instanceof Error) {
|
||||
if ('code' in error && error.code === 'EACCES') {
|
||||
console.log(`[CLI] Permission denied accessing ${nodePath}`);
|
||||
return {
|
||||
path: null,
|
||||
error: `Permission denied accessing ${nodePath}. The CLI may have been installed with sudo privileges. Try reinstalling without sudo or adjusting file permissions.`,
|
||||
};
|
||||
} else if ('code' in error && error.code === 'ENOENT') {
|
||||
// File not found, continue to next pattern
|
||||
continue;
|
||||
} else {
|
||||
console.log(`[CLI] Error accessing ${nodePath}: ${error.message}`);
|
||||
return {
|
||||
path: null,
|
||||
error: `Error accessing Node.js at ${nodePath}: ${error.message}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Try to find node in the same directory as the CLI
|
||||
const cliDir = cliPath.substring(0, cliPath.lastIndexOf('/'));
|
||||
const potentialNodePaths = [`${cliDir}/node`, `${cliDir}/bin/node`];
|
||||
|
||||
for (const nodePath of potentialNodePaths) {
|
||||
try {
|
||||
const stats = statSync(nodePath);
|
||||
if (stats.isFile()) {
|
||||
if (stats.mode & 0o111) {
|
||||
console.log(
|
||||
`[CLI] Found Node.js executable in CLI directory at: ${nodePath}`,
|
||||
);
|
||||
return { path: nodePath };
|
||||
} else {
|
||||
console.log(`[CLI] Node.js found at ${nodePath} but not executable`);
|
||||
return {
|
||||
path: null,
|
||||
error: `Node.js found at ${nodePath} but not executable. You may need to fix file permissions or reinstall the CLI.`,
|
||||
};
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Differentiate between error types
|
||||
if (error instanceof Error) {
|
||||
if ('code' in error && error.code === 'EACCES') {
|
||||
console.log(`[CLI] Permission denied accessing ${nodePath}`);
|
||||
return {
|
||||
path: null,
|
||||
error: `Permission denied accessing ${nodePath}. The CLI may have been installed with sudo privileges. Try reinstalling without sudo or adjusting file permissions.`,
|
||||
};
|
||||
} else if ('code' in error && error.code === 'ENOENT') {
|
||||
// File not found, continue
|
||||
continue;
|
||||
} else {
|
||||
console.log(`[CLI] Error accessing ${nodePath}: ${error.message}`);
|
||||
return {
|
||||
path: null,
|
||||
error: `Error accessing Node.js at ${nodePath}: ${error.message}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[CLI] Could not determine Node.js path for CLI: ${cliPath}`);
|
||||
return {
|
||||
path: null,
|
||||
error: `Could not find Node.js executable for CLI at ${cliPath}. Please verify the CLI installation.`,
|
||||
};
|
||||
}
|
||||
191
packages/vscode-ide-companion/src/cli/cliVersionManager.ts
Normal file
191
packages/vscode-ide-companion/src/cli/cliVersionManager.ts
Normal file
@@ -0,0 +1,191 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import semver from 'semver';
|
||||
import { CliDetector, type CliDetectionResult } from './cliDetector.js';
|
||||
|
||||
export const MIN_CLI_VERSION_FOR_SESSION_METHODS = '0.4.0';
|
||||
|
||||
export interface CliFeatureFlags {
|
||||
supportsSessionList: boolean;
|
||||
supportsSessionLoad: boolean;
|
||||
}
|
||||
|
||||
export interface CliVersionInfo {
|
||||
version: string | undefined;
|
||||
isSupported: boolean;
|
||||
features: CliFeatureFlags;
|
||||
detectionResult: CliDetectionResult;
|
||||
}
|
||||
|
||||
/**
|
||||
* CLI Version Manager
|
||||
*
|
||||
* Manages CLI version detection and feature availability based on version
|
||||
*/
|
||||
export class CliVersionManager {
|
||||
private static instance: CliVersionManager;
|
||||
private cachedVersionInfo: CliVersionInfo | null = null;
|
||||
private lastCheckTime: number = 0;
|
||||
private static readonly CACHE_DURATION_MS = 30000; // 30 seconds
|
||||
|
||||
private constructor() {}
|
||||
|
||||
/**
|
||||
* Get singleton instance
|
||||
*/
|
||||
static getInstance(): CliVersionManager {
|
||||
if (!CliVersionManager.instance) {
|
||||
CliVersionManager.instance = new CliVersionManager();
|
||||
}
|
||||
return CliVersionManager.instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if CLI version meets minimum requirements
|
||||
*
|
||||
* @param version - Version string to check
|
||||
* @param minVersion - Minimum required version
|
||||
* @returns Whether version meets requirements
|
||||
*/
|
||||
private isVersionSupported(
|
||||
version: string | undefined,
|
||||
minVersion: string,
|
||||
): boolean {
|
||||
if (!version) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Use semver for robust comparison (handles v-prefix, pre-release, etc.)
|
||||
const v = semver.valid(version) ?? semver.coerce(version)?.version ?? null;
|
||||
const min =
|
||||
semver.valid(minVersion) ?? semver.coerce(minVersion)?.version ?? null;
|
||||
|
||||
if (!v || !min) {
|
||||
console.warn(
|
||||
`[CliVersionManager] Invalid semver: version=${version}, min=${minVersion}`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
console.log(`[CliVersionManager] Version ${v} meets requirements: ${min}`);
|
||||
return semver.gte(v, min);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get feature flags based on CLI version
|
||||
*
|
||||
* @param version - CLI version string
|
||||
* @returns Feature flags
|
||||
*/
|
||||
private getFeatureFlags(version: string | undefined): CliFeatureFlags {
|
||||
const isSupportedVersion = this.isVersionSupported(
|
||||
version,
|
||||
MIN_CLI_VERSION_FOR_SESSION_METHODS,
|
||||
);
|
||||
|
||||
return {
|
||||
supportsSessionList: isSupportedVersion,
|
||||
supportsSessionLoad: isSupportedVersion,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect CLI version and features
|
||||
*
|
||||
* @param forceRefresh - Force a new check, ignoring cache
|
||||
* @returns CLI version information
|
||||
*/
|
||||
async detectCliVersion(forceRefresh = false): Promise<CliVersionInfo> {
|
||||
const now = Date.now();
|
||||
|
||||
// Return cached result if available and not expired
|
||||
if (
|
||||
!forceRefresh &&
|
||||
this.cachedVersionInfo &&
|
||||
now - this.lastCheckTime < CliVersionManager.CACHE_DURATION_MS
|
||||
) {
|
||||
console.log('[CliVersionManager] Returning cached version info');
|
||||
return this.cachedVersionInfo;
|
||||
}
|
||||
|
||||
console.log('[CliVersionManager] Detecting CLI version...');
|
||||
|
||||
try {
|
||||
// Detect CLI installation
|
||||
const detectionResult = await CliDetector.detectQwenCli(forceRefresh);
|
||||
|
||||
const versionInfo: CliVersionInfo = {
|
||||
version: detectionResult.version,
|
||||
isSupported: this.isVersionSupported(
|
||||
detectionResult.version,
|
||||
MIN_CLI_VERSION_FOR_SESSION_METHODS,
|
||||
),
|
||||
features: this.getFeatureFlags(detectionResult.version),
|
||||
detectionResult,
|
||||
};
|
||||
|
||||
// Cache the result
|
||||
this.cachedVersionInfo = versionInfo;
|
||||
this.lastCheckTime = now;
|
||||
|
||||
console.log(
|
||||
'[CliVersionManager] CLI version detection result:',
|
||||
versionInfo,
|
||||
);
|
||||
|
||||
return versionInfo;
|
||||
} catch (error) {
|
||||
console.error('[CliVersionManager] Failed to detect CLI version:', error);
|
||||
|
||||
// Return fallback result
|
||||
const fallbackResult: CliVersionInfo = {
|
||||
version: undefined,
|
||||
isSupported: false,
|
||||
features: {
|
||||
supportsSessionList: false,
|
||||
supportsSessionLoad: false,
|
||||
},
|
||||
detectionResult: {
|
||||
isInstalled: false,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
};
|
||||
|
||||
return fallbackResult;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear cached version information
|
||||
*/
|
||||
clearCache(): void {
|
||||
this.cachedVersionInfo = null;
|
||||
this.lastCheckTime = 0;
|
||||
CliDetector.clearCache();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if CLI supports session/list method
|
||||
*
|
||||
* @param forceRefresh - Force a new check, ignoring cache
|
||||
* @returns Whether session/list is supported
|
||||
*/
|
||||
async supportsSessionList(forceRefresh = false): Promise<boolean> {
|
||||
const versionInfo = await this.detectCliVersion(forceRefresh);
|
||||
return versionInfo.features.supportsSessionList;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if CLI supports session/load method
|
||||
*
|
||||
* @param forceRefresh - Force a new check, ignoring cache
|
||||
* @returns Whether session/load is supported
|
||||
*/
|
||||
async supportsSessionLoad(forceRefresh = false): Promise<boolean> {
|
||||
const versionInfo = await this.detectCliVersion(forceRefresh);
|
||||
return versionInfo.features.supportsSessionLoad;
|
||||
}
|
||||
}
|
||||
80
packages/vscode-ide-companion/src/commands/index.ts
Normal file
80
packages/vscode-ide-companion/src/commands/index.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import * as vscode from 'vscode';
|
||||
import type { DiffManager } from '../diff-manager.js';
|
||||
import type { WebViewProvider } from '../webview/WebViewProvider.js';
|
||||
|
||||
type Logger = (message: string) => void;
|
||||
|
||||
export const runQwenCodeCommand = 'qwen-code.runQwenCode';
|
||||
export const showDiffCommand = 'qwenCode.showDiff';
|
||||
export const openChatCommand = 'qwen-code.openChat';
|
||||
export const openNewChatTabCommand = 'qwenCode.openNewChatTab';
|
||||
export const loginCommand = 'qwen-code.login';
|
||||
|
||||
export function registerNewCommands(
|
||||
context: vscode.ExtensionContext,
|
||||
log: Logger,
|
||||
diffManager: DiffManager,
|
||||
getWebViewProviders: () => WebViewProvider[],
|
||||
createWebViewProvider: () => WebViewProvider,
|
||||
): void {
|
||||
const disposables: vscode.Disposable[] = [];
|
||||
|
||||
disposables.push(
|
||||
vscode.commands.registerCommand(openChatCommand, async () => {
|
||||
const providers = getWebViewProviders();
|
||||
if (providers.length > 0) {
|
||||
await providers[providers.length - 1].show();
|
||||
} else {
|
||||
const provider = createWebViewProvider();
|
||||
await provider.show();
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
disposables.push(
|
||||
vscode.commands.registerCommand(
|
||||
showDiffCommand,
|
||||
async (args: { path: string; oldText: string; newText: string }) => {
|
||||
try {
|
||||
let absolutePath = args.path;
|
||||
if (!args.path.startsWith('/') && !args.path.match(/^[a-zA-Z]:/)) {
|
||||
const workspaceFolder = vscode.workspace.workspaceFolders?.[0];
|
||||
if (workspaceFolder) {
|
||||
absolutePath = vscode.Uri.joinPath(
|
||||
workspaceFolder.uri,
|
||||
args.path,
|
||||
).fsPath;
|
||||
}
|
||||
}
|
||||
log(`[Command] Showing diff for ${absolutePath}`);
|
||||
await diffManager.showDiff(absolutePath, args.oldText, args.newText);
|
||||
} catch (error) {
|
||||
log(`[Command] Error showing diff: ${error}`);
|
||||
vscode.window.showErrorMessage(`Failed to show diff: ${error}`);
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
disposables.push(
|
||||
vscode.commands.registerCommand(openNewChatTabCommand, async () => {
|
||||
const provider = createWebViewProvider();
|
||||
// Session restoration is now disabled by default, so no need to suppress it
|
||||
await provider.show();
|
||||
}),
|
||||
);
|
||||
|
||||
disposables.push(
|
||||
vscode.commands.registerCommand(loginCommand, async () => {
|
||||
const providers = getWebViewProviders();
|
||||
if (providers.length > 0) {
|
||||
await providers[providers.length - 1].forceReLogin();
|
||||
} else {
|
||||
vscode.window.showInformationMessage(
|
||||
'Please open Qwen Code chat first before logging in.',
|
||||
);
|
||||
}
|
||||
}),
|
||||
);
|
||||
context.subscriptions.push(...disposables);
|
||||
}
|
||||
24
packages/vscode-ide-companion/src/constants/acpSchema.ts
Normal file
24
packages/vscode-ide-companion/src/constants/acpSchema.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
export const AGENT_METHODS = {
|
||||
authenticate: 'authenticate',
|
||||
initialize: 'initialize',
|
||||
session_cancel: 'session/cancel',
|
||||
session_list: 'session/list',
|
||||
session_load: 'session/load',
|
||||
session_new: 'session/new',
|
||||
session_prompt: 'session/prompt',
|
||||
session_save: 'session/save',
|
||||
session_set_mode: 'session/set_mode',
|
||||
} as const;
|
||||
|
||||
export const CLIENT_METHODS = {
|
||||
fs_read_text_file: 'fs/read_text_file',
|
||||
fs_write_text_file: 'fs/write_text_file',
|
||||
session_request_permission: 'session/request_permission',
|
||||
session_update: 'session/update',
|
||||
} as const;
|
||||
146
packages/vscode-ide-companion/src/constants/loadingMessages.ts
Normal file
146
packages/vscode-ide-companion/src/constants/loadingMessages.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* Loading messages from Qwen Code CLI
|
||||
* Source: packages/cli/src/ui/hooks/usePhraseCycler.ts
|
||||
*/
|
||||
export const WITTY_LOADING_PHRASES = [
|
||||
"I'm Feeling Lucky",
|
||||
'Shipping awesomeness... ',
|
||||
'Painting the serifs back on...',
|
||||
'Navigating the slime mold...',
|
||||
'Consulting the digital spirits...',
|
||||
'Reticulating splines...',
|
||||
'Warming up the AI hamsters...',
|
||||
'Asking the magic conch shell...',
|
||||
'Generating witty retort...',
|
||||
'Polishing the algorithms...',
|
||||
"Don't rush perfection (or my code)...",
|
||||
'Brewing fresh bytes...',
|
||||
'Counting electrons...',
|
||||
'Engaging cognitive processors...',
|
||||
'Checking for syntax errors in the universe...',
|
||||
'One moment, optimizing humor...',
|
||||
'Shuffling punchlines...',
|
||||
'Untangling neural nets...',
|
||||
'Compiling brilliance...',
|
||||
'Loading wit.exe...',
|
||||
'Summoning the cloud of wisdom...',
|
||||
'Preparing a witty response...',
|
||||
"Just a sec, I'm debugging reality...",
|
||||
'Confuzzling the options...',
|
||||
'Tuning the cosmic frequencies...',
|
||||
'Crafting a response worthy of your patience...',
|
||||
'Compiling the 1s and 0s...',
|
||||
'Resolving dependencies... and existential crises...',
|
||||
'Defragmenting memories... both RAM and personal...',
|
||||
'Rebooting the humor module...',
|
||||
'Caching the essentials (mostly cat memes)...',
|
||||
'Optimizing for ludicrous speed',
|
||||
"Swapping bits... don't tell the bytes...",
|
||||
'Garbage collecting... be right back...',
|
||||
'Assembling the interwebs...',
|
||||
'Converting coffee into code...',
|
||||
'Updating the syntax for reality...',
|
||||
'Rewiring the synapses...',
|
||||
'Looking for a misplaced semicolon...',
|
||||
"Greasin' the cogs of the machine...",
|
||||
'Pre-heating the servers...',
|
||||
'Calibrating the flux capacitor...',
|
||||
'Engaging the improbability drive...',
|
||||
'Channeling the Force...',
|
||||
'Aligning the stars for optimal response...',
|
||||
'So say we all...',
|
||||
'Loading the next great idea...',
|
||||
"Just a moment, I'm in the zone...",
|
||||
'Preparing to dazzle you with brilliance...',
|
||||
"Just a tick, I'm polishing my wit...",
|
||||
"Hold tight, I'm crafting a masterpiece...",
|
||||
"Just a jiffy, I'm debugging the universe...",
|
||||
"Just a moment, I'm aligning the pixels...",
|
||||
"Just a sec, I'm optimizing the humor...",
|
||||
"Just a moment, I'm tuning the algorithms...",
|
||||
'Warp speed engaged...',
|
||||
'Mining for more Dilithium crystals...',
|
||||
"Don't panic...",
|
||||
'Following the white rabbit...',
|
||||
'The truth is in here... somewhere...',
|
||||
'Blowing on the cartridge...',
|
||||
'Loading... Do a barrel roll!',
|
||||
'Waiting for the respawn...',
|
||||
'Finishing the Kessel Run in less than 12 parsecs...',
|
||||
"The cake is not a lie, it's just still loading...",
|
||||
'Fiddling with the character creation screen...',
|
||||
"Just a moment, I'm finding the right meme...",
|
||||
"Pressing 'A' to continue...",
|
||||
'Herding digital cats...',
|
||||
'Polishing the pixels...',
|
||||
'Finding a suitable loading screen pun...',
|
||||
'Distracting you with this witty phrase...',
|
||||
'Almost there... probably...',
|
||||
'Our hamsters are working as fast as they can...',
|
||||
'Giving Cloudy a pat on the head...',
|
||||
'Petting the cat...',
|
||||
'Rickrolling my boss...',
|
||||
'Never gonna give you up, never gonna let you down...',
|
||||
'Slapping the bass...',
|
||||
'Tasting the snozberries...',
|
||||
"I'm going the distance, I'm going for speed...",
|
||||
'Is this the real life? Is this just fantasy?...',
|
||||
"I've got a good feeling about this...",
|
||||
'Poking the bear...',
|
||||
'Doing research on the latest memes...',
|
||||
'Figuring out how to make this more witty...',
|
||||
'Hmmm... let me think...',
|
||||
'What do you call a fish with no eyes? A fsh...',
|
||||
'Why did the computer go to therapy? It had too many bytes...',
|
||||
"Why don't programmers like nature? It has too many bugs...",
|
||||
'Why do programmers prefer dark mode? Because light attracts bugs...',
|
||||
'Why did the developer go broke? Because they used up all their cache...',
|
||||
"What can you do with a broken pencil? Nothing, it's pointless...",
|
||||
'Applying percussive maintenance...',
|
||||
'Searching for the correct USB orientation...',
|
||||
'Ensuring the magic smoke stays inside the wires...',
|
||||
'Rewriting in Rust for no particular reason...',
|
||||
'Trying to exit Vim...',
|
||||
'Spinning up the hamster wheel...',
|
||||
"That's not a bug, it's an undocumented feature...",
|
||||
'Engage.',
|
||||
"I'll be back... with an answer.",
|
||||
'My other process is a TARDIS...',
|
||||
'Communing with the machine spirit...',
|
||||
'Letting the thoughts marinate...',
|
||||
'Just remembered where I put my keys...',
|
||||
'Pondering the orb...',
|
||||
"I've seen things you people wouldn't believe... like a user who reads loading messages.",
|
||||
'Initiating thoughtful gaze...',
|
||||
"What's a computer's favorite snack? Microchips.",
|
||||
"Why do Java developers wear glasses? Because they don't C#.",
|
||||
'Charging the laser... pew pew!',
|
||||
'Dividing by zero... just kidding!',
|
||||
'Looking for an adult superviso... I mean, processing.',
|
||||
'Making it go beep boop.',
|
||||
'Buffering... because even AIs need a moment.',
|
||||
'Entangling quantum particles for a faster response...',
|
||||
'Polishing the chrome... on the algorithms.',
|
||||
'Are you not entertained? (Working on it!)',
|
||||
'Summoning the code gremlins... to help, of course.',
|
||||
'Just waiting for the dial-up tone to finish...',
|
||||
'Recalibrating the humor-o-meter.',
|
||||
'My other loading screen is even funnier.',
|
||||
"Pretty sure there's a cat walking on the keyboard somewhere...",
|
||||
'Enhancing... Enhancing... Still loading.',
|
||||
"It's not a bug, it's a feature... of this loading screen.",
|
||||
'Have you tried turning it off and on again? (The loading screen, not me.)',
|
||||
'Constructing additional pylons...',
|
||||
"New line? That's Ctrl+J.",
|
||||
];
|
||||
|
||||
export const getRandomLoadingMessage = (): string =>
|
||||
WITTY_LOADING_PHRASES[
|
||||
Math.floor(Math.random() * WITTY_LOADING_PHRASES.length)
|
||||
];
|
||||
@@ -12,6 +12,10 @@ import { type JSONRPCNotification } from '@modelcontextprotocol/sdk/types.js';
|
||||
import * as path from 'node:path';
|
||||
import * as vscode from 'vscode';
|
||||
import { DIFF_SCHEME } from './extension.js';
|
||||
import {
|
||||
findLeftGroupOfChatWebview,
|
||||
ensureLeftGroupOfChatWebview,
|
||||
} from './utils/editorGroupUtils.js';
|
||||
|
||||
export class DiffContentProvider implements vscode.TextDocumentContentProvider {
|
||||
private content = new Map<string, string>();
|
||||
@@ -42,7 +46,9 @@ export class DiffContentProvider implements vscode.TextDocumentContentProvider {
|
||||
// Information about a diff view that is currently open.
|
||||
interface DiffInfo {
|
||||
originalFilePath: string;
|
||||
oldContent: string;
|
||||
newContent: string;
|
||||
leftDocUri: vscode.Uri;
|
||||
rightDocUri: vscode.Uri;
|
||||
}
|
||||
|
||||
@@ -55,11 +61,26 @@ export class DiffManager {
|
||||
readonly onDidChange = this.onDidChangeEmitter.event;
|
||||
private diffDocuments = new Map<string, DiffInfo>();
|
||||
private readonly subscriptions: vscode.Disposable[] = [];
|
||||
// Dedupe: remember recent showDiff calls keyed by (file+content)
|
||||
private recentlyShown = new Map<string, number>();
|
||||
private pendingDelayTimers = new Map<string, NodeJS.Timeout>();
|
||||
private static readonly DEDUPE_WINDOW_MS = 1500;
|
||||
// Optional hooks from extension to influence diff behavior
|
||||
// - shouldDelay: when true, we defer opening diffs briefly (e.g., while a permission drawer is open)
|
||||
// - shouldSuppress: when true, we skip opening diffs entirely (e.g., in auto/yolo mode)
|
||||
private shouldDelay?: () => boolean;
|
||||
private shouldSuppress?: () => boolean;
|
||||
// Timed suppression window (e.g. immediately after permission allow)
|
||||
private suppressUntil: number | null = null;
|
||||
|
||||
constructor(
|
||||
private readonly log: (message: string) => void,
|
||||
private readonly diffContentProvider: DiffContentProvider,
|
||||
shouldDelay?: () => boolean,
|
||||
shouldSuppress?: () => boolean,
|
||||
) {
|
||||
this.shouldDelay = shouldDelay;
|
||||
this.shouldSuppress = shouldSuppress;
|
||||
this.subscriptions.push(
|
||||
vscode.window.onDidChangeActiveTextEditor((editor) => {
|
||||
this.onActiveEditorChange(editor);
|
||||
@@ -75,43 +96,142 @@ export class DiffManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates and shows a new diff view.
|
||||
* Checks if a diff view already exists for the given file path and content
|
||||
* @param filePath Path to the file being diffed
|
||||
* @param oldContent The original content (left side)
|
||||
* @param newContent The modified content (right side)
|
||||
* @returns True if a diff view with the same content already exists, false otherwise
|
||||
*/
|
||||
async showDiff(filePath: string, newContent: string) {
|
||||
const fileUri = vscode.Uri.file(filePath);
|
||||
private hasExistingDiff(
|
||||
filePath: string,
|
||||
oldContent: string,
|
||||
newContent: string,
|
||||
): boolean {
|
||||
for (const diffInfo of this.diffDocuments.values()) {
|
||||
if (
|
||||
diffInfo.originalFilePath === filePath &&
|
||||
diffInfo.oldContent === oldContent &&
|
||||
diffInfo.newContent === newContent
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds an existing diff view for the given file path and focuses it
|
||||
* @param filePath Path to the file being diffed
|
||||
* @returns True if an existing diff view was found and focused, false otherwise
|
||||
*/
|
||||
private async focusExistingDiff(filePath: string): Promise<boolean> {
|
||||
const normalizedPath = path.normalize(filePath);
|
||||
for (const [, diffInfo] of this.diffDocuments.entries()) {
|
||||
if (diffInfo.originalFilePath === normalizedPath) {
|
||||
const rightDocUri = diffInfo.rightDocUri;
|
||||
const leftDocUri = diffInfo.leftDocUri;
|
||||
|
||||
const diffTitle = `${path.basename(filePath)} (Before ↔ After)`;
|
||||
|
||||
try {
|
||||
await vscode.commands.executeCommand(
|
||||
'vscode.diff',
|
||||
leftDocUri,
|
||||
rightDocUri,
|
||||
diffTitle,
|
||||
{
|
||||
viewColumn: vscode.ViewColumn.Beside,
|
||||
preview: false,
|
||||
preserveFocus: true,
|
||||
},
|
||||
);
|
||||
return true;
|
||||
} catch (error) {
|
||||
this.log(`Failed to focus existing diff: ${error}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates and shows a new diff view.
|
||||
* - Overload 1: showDiff(filePath, newContent)
|
||||
* - Overload 2: showDiff(filePath, oldContent, newContent)
|
||||
* If only newContent is provided, the old content will be read from the
|
||||
* filesystem (empty string when file does not exist).
|
||||
*/
|
||||
async showDiff(filePath: string, newContent: string): Promise<void>;
|
||||
async showDiff(
|
||||
filePath: string,
|
||||
oldContent: string,
|
||||
newContent: string,
|
||||
): Promise<void>;
|
||||
async showDiff(filePath: string, a: string, b?: string): Promise<void> {
|
||||
const haveOld = typeof b === 'string';
|
||||
const oldContent = haveOld ? a : await this.readOldContentFromFs(filePath);
|
||||
const newContent = haveOld ? (b as string) : a;
|
||||
const normalizedPath = path.normalize(filePath);
|
||||
const key = this.makeKey(normalizedPath, oldContent, newContent);
|
||||
|
||||
// Check if a diff view with the same content already exists
|
||||
if (this.hasExistingDiff(normalizedPath, oldContent, newContent)) {
|
||||
const last = this.recentlyShown.get(key) || 0;
|
||||
const now = Date.now();
|
||||
if (now - last < DiffManager.DEDUPE_WINDOW_MS) {
|
||||
// Within dedupe window: ignore the duplicate request entirely
|
||||
this.log(
|
||||
`Duplicate showDiff suppressed for ${filePath} (within ${DiffManager.DEDUPE_WINDOW_MS}ms)`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
// Outside the dedupe window: softly focus the existing diff
|
||||
await this.focusExistingDiff(normalizedPath);
|
||||
this.recentlyShown.set(key, now);
|
||||
return;
|
||||
}
|
||||
// Left side: old content using qwen-diff scheme
|
||||
const leftDocUri = vscode.Uri.from({
|
||||
scheme: DIFF_SCHEME,
|
||||
path: normalizedPath,
|
||||
query: `old&rand=${Math.random()}`,
|
||||
});
|
||||
this.diffContentProvider.setContent(leftDocUri, oldContent);
|
||||
|
||||
// Right side: new content using qwen-diff scheme
|
||||
const rightDocUri = vscode.Uri.from({
|
||||
scheme: DIFF_SCHEME,
|
||||
path: filePath,
|
||||
// cache busting
|
||||
query: `rand=${Math.random()}`,
|
||||
path: normalizedPath,
|
||||
query: `new&rand=${Math.random()}`,
|
||||
});
|
||||
this.diffContentProvider.setContent(rightDocUri, newContent);
|
||||
|
||||
this.addDiffDocument(rightDocUri, {
|
||||
originalFilePath: filePath,
|
||||
originalFilePath: normalizedPath,
|
||||
oldContent,
|
||||
newContent,
|
||||
leftDocUri,
|
||||
rightDocUri,
|
||||
});
|
||||
|
||||
const diffTitle = `${path.basename(filePath)} ↔ Modified`;
|
||||
const diffTitle = `${path.basename(normalizedPath)} (Before ↔ After)`;
|
||||
await vscode.commands.executeCommand(
|
||||
'setContext',
|
||||
'qwen.diff.isVisible',
|
||||
true,
|
||||
);
|
||||
|
||||
let leftDocUri;
|
||||
try {
|
||||
await vscode.workspace.fs.stat(fileUri);
|
||||
leftDocUri = fileUri;
|
||||
} catch {
|
||||
// We need to provide an empty document to diff against.
|
||||
// Using the 'untitled' scheme is one way to do this.
|
||||
leftDocUri = vscode.Uri.from({
|
||||
scheme: 'untitled',
|
||||
path: filePath,
|
||||
});
|
||||
// Prefer opening the diff adjacent to the chat webview (so we don't
|
||||
// replace content inside the locked webview group). We try the group to
|
||||
// the left of the chat webview first; if none exists we fall back to
|
||||
// ViewColumn.Beside. With the chat locked in the leftmost group, this
|
||||
// fallback opens diffs to the right of the chat.
|
||||
let targetViewColumn = findLeftGroupOfChatWebview();
|
||||
if (targetViewColumn === undefined) {
|
||||
// If there is no left neighbor, create one to satisfy the requirement of
|
||||
// opening diffs to the left of the chat webview.
|
||||
targetViewColumn = await ensureLeftGroupOfChatWebview();
|
||||
}
|
||||
|
||||
await vscode.commands.executeCommand(
|
||||
@@ -120,6 +240,10 @@ export class DiffManager {
|
||||
rightDocUri,
|
||||
diffTitle,
|
||||
{
|
||||
// If a left-of-webview group was found, target it explicitly so the
|
||||
// diff opens there while keeping focus on the webview. Otherwise, use
|
||||
// the default "open to side" behavior.
|
||||
viewColumn: targetViewColumn ?? vscode.ViewColumn.Beside,
|
||||
preview: false,
|
||||
preserveFocus: true,
|
||||
},
|
||||
@@ -127,16 +251,19 @@ export class DiffManager {
|
||||
await vscode.commands.executeCommand(
|
||||
'workbench.action.files.setActiveEditorWriteableInSession',
|
||||
);
|
||||
|
||||
this.recentlyShown.set(key, Date.now());
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes an open diff view for a specific file.
|
||||
*/
|
||||
async closeDiff(filePath: string, suppressNotification = false) {
|
||||
const normalizedPath = path.normalize(filePath);
|
||||
let uriToClose: vscode.Uri | undefined;
|
||||
for (const [uriString, diffInfo] of this.diffDocuments.entries()) {
|
||||
if (diffInfo.originalFilePath === filePath) {
|
||||
uriToClose = vscode.Uri.parse(uriString);
|
||||
for (const [, diffInfo] of this.diffDocuments.entries()) {
|
||||
if (diffInfo.originalFilePath === normalizedPath) {
|
||||
uriToClose = diffInfo.rightDocUri;
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -267,4 +394,40 @@ export class DiffManager {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Close all open qwen-diff editors */
|
||||
async closeAll(): Promise<void> {
|
||||
// Collect keys first to avoid iterator invalidation while closing
|
||||
const uris = Array.from(this.diffDocuments.keys()).map((k) =>
|
||||
vscode.Uri.parse(k),
|
||||
);
|
||||
for (const uri of uris) {
|
||||
try {
|
||||
await this.closeDiffEditor(uri);
|
||||
} catch (err) {
|
||||
this.log(`Failed to close diff editor: ${err}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Read the current content of file from the workspace; return empty string if not found
|
||||
private async readOldContentFromFs(filePath: string): Promise<string> {
|
||||
try {
|
||||
const fileUri = vscode.Uri.file(filePath);
|
||||
const document = await vscode.workspace.openTextDocument(fileUri);
|
||||
return document.getText();
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
private makeKey(filePath: string, oldContent: string, newContent: string) {
|
||||
// Simple stable key; content could be large but kept transiently
|
||||
return `${filePath}\u241F${oldContent}\u241F${newContent}`;
|
||||
}
|
||||
|
||||
/** Temporarily suppress opening diffs for a short duration. */
|
||||
suppressFor(durationMs: number): void {
|
||||
this.suppressUntil = Date.now() + Math.max(0, durationMs);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,6 +40,9 @@ vi.mock('vscode', () => ({
|
||||
},
|
||||
showTextDocument: vi.fn(),
|
||||
showWorkspaceFolderPick: vi.fn(),
|
||||
registerWebviewPanelSerializer: vi.fn(() => ({
|
||||
dispose: vi.fn(),
|
||||
})),
|
||||
},
|
||||
workspace: {
|
||||
workspaceFolders: [],
|
||||
|
||||
@@ -14,6 +14,8 @@ import {
|
||||
IDE_DEFINITIONS,
|
||||
type IdeInfo,
|
||||
} from '@qwen-code/qwen-code-core/src/ide/detect-ide.js';
|
||||
import { WebViewProvider } from './webview/WebViewProvider.js';
|
||||
import { registerNewCommands } from './commands/index.js';
|
||||
|
||||
const CLI_IDE_COMPANION_IDENTIFIER = 'qwenlm.qwen-code-vscode-ide-companion';
|
||||
const INFO_MESSAGE_SHOWN_KEY = 'qwenCodeInfoMessageShown';
|
||||
@@ -31,6 +33,7 @@ const HIDE_INSTALLATION_GREETING_IDES: ReadonlySet<IdeInfo['name']> = new Set([
|
||||
|
||||
let ideServer: IDEServer;
|
||||
let logger: vscode.OutputChannel;
|
||||
let webViewProviders: WebViewProvider[] = []; // Track multiple chat tabs
|
||||
|
||||
let log: (message: string) => void = () => {};
|
||||
|
||||
@@ -108,7 +111,75 @@ export async function activate(context: vscode.ExtensionContext) {
|
||||
checkForUpdates(context, log);
|
||||
|
||||
const diffContentProvider = new DiffContentProvider();
|
||||
const diffManager = new DiffManager(log, diffContentProvider);
|
||||
const diffManager = new DiffManager(
|
||||
log,
|
||||
diffContentProvider,
|
||||
// Delay when any chat tab has a pending permission drawer
|
||||
() => webViewProviders.some((p) => p.hasPendingPermission()),
|
||||
// Suppress diffs when active mode is auto or yolo in any chat tab
|
||||
() => {
|
||||
const providers = webViewProviders.filter(
|
||||
(p) => typeof p.shouldSuppressDiff === 'function',
|
||||
);
|
||||
if (providers.length === 0) {
|
||||
return false;
|
||||
}
|
||||
return providers.every((p) => p.shouldSuppressDiff());
|
||||
},
|
||||
);
|
||||
|
||||
// Helper function to create a new WebView provider instance
|
||||
const createWebViewProvider = (): WebViewProvider => {
|
||||
const provider = new WebViewProvider(context, context.extensionUri);
|
||||
webViewProviders.push(provider);
|
||||
return provider;
|
||||
};
|
||||
|
||||
// Register WebView panel serializer for persistence across reloads
|
||||
context.subscriptions.push(
|
||||
vscode.window.registerWebviewPanelSerializer('qwenCode.chat', {
|
||||
async deserializeWebviewPanel(
|
||||
webviewPanel: vscode.WebviewPanel,
|
||||
state: unknown,
|
||||
) {
|
||||
console.log(
|
||||
'[Extension] Deserializing WebView panel with state:',
|
||||
state,
|
||||
);
|
||||
|
||||
// Create a new provider for the restored panel
|
||||
const provider = createWebViewProvider();
|
||||
console.log('[Extension] Provider created for deserialization');
|
||||
|
||||
// Restore state if available BEFORE restoring the panel
|
||||
if (state && typeof state === 'object') {
|
||||
console.log('[Extension] Restoring state:', state);
|
||||
provider.restoreState(
|
||||
state as {
|
||||
conversationId: string | null;
|
||||
agentInitialized: boolean;
|
||||
},
|
||||
);
|
||||
} else {
|
||||
console.log('[Extension] No state to restore or invalid state');
|
||||
}
|
||||
|
||||
await provider.restorePanel(webviewPanel);
|
||||
console.log('[Extension] Panel restore completed');
|
||||
|
||||
log('WebView panel restored from serialization');
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
// Register newly added commands via commands module
|
||||
registerNewCommands(
|
||||
context,
|
||||
log,
|
||||
diffManager,
|
||||
() => webViewProviders,
|
||||
createWebViewProvider,
|
||||
);
|
||||
|
||||
context.subscriptions.push(
|
||||
vscode.workspace.onDidCloseTextDocument((doc) => {
|
||||
@@ -120,17 +191,53 @@ export async function activate(context: vscode.ExtensionContext) {
|
||||
DIFF_SCHEME,
|
||||
diffContentProvider,
|
||||
),
|
||||
vscode.commands.registerCommand('qwen.diff.accept', (uri?: vscode.Uri) => {
|
||||
(vscode.commands.registerCommand('qwen.diff.accept', (uri?: vscode.Uri) => {
|
||||
const docUri = uri ?? vscode.window.activeTextEditor?.document.uri;
|
||||
if (docUri && docUri.scheme === DIFF_SCHEME) {
|
||||
diffManager.acceptDiff(docUri);
|
||||
}
|
||||
// If WebView is requesting permission, actively select an allow option (prefer once)
|
||||
try {
|
||||
for (const provider of webViewProviders) {
|
||||
if (provider?.hasPendingPermission()) {
|
||||
provider.respondToPendingPermission('allow');
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[Extension] Auto-allow on diff.accept failed:', err);
|
||||
}
|
||||
console.log('[Extension] Diff accepted');
|
||||
}),
|
||||
vscode.commands.registerCommand('qwen.diff.cancel', (uri?: vscode.Uri) => {
|
||||
const docUri = uri ?? vscode.window.activeTextEditor?.document.uri;
|
||||
if (docUri && docUri.scheme === DIFF_SCHEME) {
|
||||
diffManager.cancelDiff(docUri);
|
||||
}
|
||||
// If WebView is requesting permission, actively select reject/cancel
|
||||
try {
|
||||
for (const provider of webViewProviders) {
|
||||
if (provider?.hasPendingPermission()) {
|
||||
provider.respondToPendingPermission('cancel');
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[Extension] Auto-reject on diff.cancel failed:', err);
|
||||
}
|
||||
console.log('[Extension] Diff cancelled');
|
||||
})),
|
||||
vscode.commands.registerCommand('qwen.diff.closeAll', async () => {
|
||||
try {
|
||||
await diffManager.closeAll();
|
||||
} catch (err) {
|
||||
console.warn('[Extension] qwen.diff.closeAll failed:', err);
|
||||
}
|
||||
}),
|
||||
vscode.commands.registerCommand('qwen.diff.suppressBriefly', async () => {
|
||||
try {
|
||||
diffManager.suppressFor(1200);
|
||||
} catch (err) {
|
||||
console.warn('[Extension] qwen.diff.suppressBriefly failed:', err);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -160,34 +267,42 @@ export async function activate(context: vscode.ExtensionContext) {
|
||||
vscode.workspace.onDidGrantWorkspaceTrust(() => {
|
||||
ideServer.syncEnvVars();
|
||||
}),
|
||||
vscode.commands.registerCommand('qwen-code.runQwenCode', async () => {
|
||||
const workspaceFolders = vscode.workspace.workspaceFolders;
|
||||
if (!workspaceFolders || workspaceFolders.length === 0) {
|
||||
vscode.window.showInformationMessage(
|
||||
'No folder open. Please open a folder to run Qwen Code.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
vscode.commands.registerCommand(
|
||||
'qwen-code.runQwenCode',
|
||||
async (
|
||||
location?:
|
||||
| vscode.TerminalLocation
|
||||
| vscode.TerminalEditorLocationOptions,
|
||||
) => {
|
||||
const workspaceFolders = vscode.workspace.workspaceFolders;
|
||||
if (!workspaceFolders || workspaceFolders.length === 0) {
|
||||
vscode.window.showInformationMessage(
|
||||
'No folder open. Please open a folder to run Qwen Code.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let selectedFolder: vscode.WorkspaceFolder | undefined;
|
||||
if (workspaceFolders.length === 1) {
|
||||
selectedFolder = workspaceFolders[0];
|
||||
} else {
|
||||
selectedFolder = await vscode.window.showWorkspaceFolderPick({
|
||||
placeHolder: 'Select a folder to run Qwen Code in',
|
||||
});
|
||||
}
|
||||
let selectedFolder: vscode.WorkspaceFolder | undefined;
|
||||
if (workspaceFolders.length === 1) {
|
||||
selectedFolder = workspaceFolders[0];
|
||||
} else {
|
||||
selectedFolder = await vscode.window.showWorkspaceFolderPick({
|
||||
placeHolder: 'Select a folder to run Qwen Code in',
|
||||
});
|
||||
}
|
||||
|
||||
if (selectedFolder) {
|
||||
const qwenCmd = 'qwen';
|
||||
const terminal = vscode.window.createTerminal({
|
||||
name: `Qwen Code (${selectedFolder.name})`,
|
||||
cwd: selectedFolder.uri.fsPath,
|
||||
});
|
||||
terminal.show();
|
||||
terminal.sendText(qwenCmd);
|
||||
}
|
||||
}),
|
||||
if (selectedFolder) {
|
||||
const qwenCmd = 'qwen';
|
||||
const terminal = vscode.window.createTerminal({
|
||||
name: `Qwen Code (${selectedFolder.name})`,
|
||||
cwd: selectedFolder.uri.fsPath,
|
||||
location,
|
||||
});
|
||||
terminal.show();
|
||||
terminal.sendText(qwenCmd);
|
||||
}
|
||||
},
|
||||
),
|
||||
vscode.commands.registerCommand('qwen-code.showNotices', async () => {
|
||||
const noticePath = vscode.Uri.joinPath(
|
||||
context.extensionUri,
|
||||
@@ -204,6 +319,11 @@ export async function deactivate(): Promise<void> {
|
||||
if (ideServer) {
|
||||
await ideServer.stop();
|
||||
}
|
||||
// Dispose all WebView providers
|
||||
webViewProviders.forEach((provider) => {
|
||||
provider.dispose();
|
||||
});
|
||||
webViewProviders = [];
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
log(`Failed to stop IDE server during deactivation: ${message}`);
|
||||
|
||||
@@ -437,6 +437,7 @@ const createMcpServer = (diffManager: DiffManager) => {
|
||||
inputSchema: OpenDiffRequestSchema.shape,
|
||||
},
|
||||
async ({ filePath, newContent }: z.infer<typeof OpenDiffRequestSchema>) => {
|
||||
// Minimal call site: only pass newContent; DiffManager reads old content itself
|
||||
await diffManager.showDiff(filePath, newContent);
|
||||
return { content: [] };
|
||||
},
|
||||
|
||||
@@ -414,7 +414,7 @@ describe('OpenFilesManager', () => {
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
|
||||
file1 = manager.state.workspaceState!.openFiles!.find(
|
||||
(f) => f.path === '/test/file1.txt',
|
||||
(f: { path: string }) => f.path === '/test/file1.txt',
|
||||
)!;
|
||||
const file2 = manager.state.workspaceState!.openFiles![0];
|
||||
|
||||
|
||||
426
packages/vscode-ide-companion/src/services/acpConnection.ts
Normal file
426
packages/vscode-ide-companion/src/services/acpConnection.ts
Normal file
@@ -0,0 +1,426 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { JSONRPC_VERSION } from '../types/acpTypes.js';
|
||||
import type {
|
||||
AcpMessage,
|
||||
AcpPermissionRequest,
|
||||
AcpResponse,
|
||||
AcpSessionUpdate,
|
||||
ApprovalModeValue,
|
||||
} from '../types/acpTypes.js';
|
||||
import type { ChildProcess, SpawnOptions } from 'child_process';
|
||||
import { spawn } from 'child_process';
|
||||
import type {
|
||||
PendingRequest,
|
||||
AcpConnectionCallbacks,
|
||||
} from '../types/connectionTypes.js';
|
||||
import { AcpMessageHandler } from './acpMessageHandler.js';
|
||||
import { AcpSessionManager } from './acpSessionManager.js';
|
||||
import { determineNodePathForCli } from '../cli/cliPathDetector.js';
|
||||
|
||||
/**
|
||||
* ACP Connection Handler for VSCode Extension
|
||||
*
|
||||
* This class implements the client side of the ACP (Agent Communication Protocol).
|
||||
*/
|
||||
export class AcpConnection {
|
||||
private child: ChildProcess | null = null;
|
||||
private pendingRequests = new Map<number, PendingRequest<unknown>>();
|
||||
private nextRequestId = { value: 0 };
|
||||
// Remember the working dir provided at connect() so later ACP calls
|
||||
// that require cwd (e.g. session/list) can include it.
|
||||
private workingDir: string = process.cwd();
|
||||
|
||||
private messageHandler: AcpMessageHandler;
|
||||
private sessionManager: AcpSessionManager;
|
||||
|
||||
onSessionUpdate: (data: AcpSessionUpdate) => void = () => {};
|
||||
onPermissionRequest: (data: AcpPermissionRequest) => Promise<{
|
||||
optionId: string;
|
||||
}> = () => Promise.resolve({ optionId: 'allow' });
|
||||
onEndTurn: () => void = () => {};
|
||||
// Called after successful initialize() with the initialize result
|
||||
onInitialized: (init: unknown) => void = () => {};
|
||||
|
||||
constructor() {
|
||||
this.messageHandler = new AcpMessageHandler();
|
||||
this.sessionManager = new AcpSessionManager();
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to Qwen ACP
|
||||
*
|
||||
* @param cliPath - CLI path
|
||||
* @param workingDir - Working directory
|
||||
* @param extraArgs - Extra command line arguments
|
||||
*/
|
||||
async connect(
|
||||
cliPath: string,
|
||||
workingDir: string = process.cwd(),
|
||||
extraArgs: string[] = [],
|
||||
): Promise<void> {
|
||||
if (this.child) {
|
||||
this.disconnect();
|
||||
}
|
||||
|
||||
this.workingDir = workingDir;
|
||||
|
||||
const isWindows = process.platform === 'win32';
|
||||
const env = { ...process.env };
|
||||
|
||||
// If proxy is configured in extraArgs, also set it as environment variable
|
||||
// This ensures token refresh requests also use the proxy
|
||||
const proxyArg = extraArgs.find(
|
||||
(arg, i) => arg === '--proxy' && i + 1 < extraArgs.length,
|
||||
);
|
||||
if (proxyArg) {
|
||||
const proxyIndex = extraArgs.indexOf('--proxy');
|
||||
const proxyUrl = extraArgs[proxyIndex + 1];
|
||||
console.log('[ACP] Setting proxy environment variables:', proxyUrl);
|
||||
|
||||
env['HTTP_PROXY'] = proxyUrl;
|
||||
env['HTTPS_PROXY'] = proxyUrl;
|
||||
env['http_proxy'] = proxyUrl;
|
||||
env['https_proxy'] = proxyUrl;
|
||||
}
|
||||
|
||||
let spawnCommand: string;
|
||||
let spawnArgs: string[];
|
||||
|
||||
if (cliPath.startsWith('npx ')) {
|
||||
const parts = cliPath.split(' ');
|
||||
spawnCommand = isWindows ? 'npx.cmd' : 'npx';
|
||||
spawnArgs = [...parts.slice(1), '--experimental-acp', ...extraArgs];
|
||||
} else {
|
||||
// For qwen CLI, ensure we use the correct Node.js version
|
||||
// Handle various Node.js version managers (nvm, n, manual installations)
|
||||
if (cliPath.includes('/qwen') && !isWindows) {
|
||||
// Try to determine the correct node executable for this qwen installation
|
||||
const nodePathResult = determineNodePathForCli(cliPath);
|
||||
if (nodePathResult.path) {
|
||||
spawnCommand = nodePathResult.path;
|
||||
spawnArgs = [cliPath, '--experimental-acp', ...extraArgs];
|
||||
} else {
|
||||
// Fallback to direct execution
|
||||
spawnCommand = cliPath;
|
||||
spawnArgs = ['--experimental-acp', ...extraArgs];
|
||||
|
||||
// Log any error for debugging
|
||||
if (nodePathResult.error) {
|
||||
console.warn(
|
||||
`[ACP] Node.js path detection warning: ${nodePathResult.error}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
spawnCommand = cliPath;
|
||||
spawnArgs = ['--experimental-acp', ...extraArgs];
|
||||
}
|
||||
}
|
||||
|
||||
console.log('[ACP] Spawning command:', spawnCommand, spawnArgs.join(' '));
|
||||
|
||||
const options: SpawnOptions = {
|
||||
cwd: workingDir,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
env,
|
||||
shell: isWindows,
|
||||
};
|
||||
|
||||
this.child = spawn(spawnCommand, spawnArgs, options);
|
||||
await this.setupChildProcessHandlers();
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up child process handlers
|
||||
*/
|
||||
private async setupChildProcessHandlers(): Promise<void> {
|
||||
let spawnError: Error | null = null;
|
||||
|
||||
this.child!.stderr?.on('data', (data) => {
|
||||
const message = data.toString();
|
||||
if (
|
||||
message.toLowerCase().includes('error') &&
|
||||
!message.includes('Loaded cached')
|
||||
) {
|
||||
console.error(`[ACP qwen]:`, message);
|
||||
} else {
|
||||
console.log(`[ACP qwen]:`, message);
|
||||
}
|
||||
});
|
||||
|
||||
this.child!.on('error', (error) => {
|
||||
spawnError = error;
|
||||
});
|
||||
|
||||
this.child!.on('exit', (code, signal) => {
|
||||
console.error(
|
||||
`[ACP qwen] Process exited with code: ${code}, signal: ${signal}`,
|
||||
);
|
||||
});
|
||||
|
||||
// Wait for process to start
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
|
||||
if (spawnError) {
|
||||
throw spawnError;
|
||||
}
|
||||
|
||||
if (!this.child || this.child.killed) {
|
||||
throw new Error(`Qwen ACP process failed to start`);
|
||||
}
|
||||
|
||||
// Handle messages from ACP server
|
||||
let buffer = '';
|
||||
this.child.stdout?.on('data', (data) => {
|
||||
buffer += data.toString();
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() || '';
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.trim()) {
|
||||
try {
|
||||
const message = JSON.parse(line) as AcpMessage;
|
||||
console.log(
|
||||
'[ACP] <<< Received message:',
|
||||
JSON.stringify(message).substring(0, 500 * 3),
|
||||
);
|
||||
this.handleMessage(message);
|
||||
} catch (_error) {
|
||||
// Ignore non-JSON lines
|
||||
console.log(
|
||||
'[ACP] <<< Non-JSON line (ignored):',
|
||||
line.substring(0, 200),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Initialize protocol
|
||||
const res = await this.sessionManager.initialize(
|
||||
this.child,
|
||||
this.pendingRequests,
|
||||
this.nextRequestId,
|
||||
);
|
||||
|
||||
console.log('[ACP] Initialization response:', res);
|
||||
try {
|
||||
this.onInitialized(res);
|
||||
} catch (err) {
|
||||
console.warn('[ACP] onInitialized callback error:', err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle received messages
|
||||
*
|
||||
* @param message - ACP message
|
||||
*/
|
||||
private handleMessage(message: AcpMessage): void {
|
||||
const callbacks: AcpConnectionCallbacks = {
|
||||
onSessionUpdate: this.onSessionUpdate,
|
||||
onPermissionRequest: this.onPermissionRequest,
|
||||
onEndTurn: this.onEndTurn,
|
||||
};
|
||||
|
||||
// Handle message
|
||||
if ('method' in message) {
|
||||
// Request or notification
|
||||
this.messageHandler
|
||||
.handleIncomingRequest(message, callbacks)
|
||||
.then((result) => {
|
||||
if ('id' in message && typeof message.id === 'number') {
|
||||
this.messageHandler.sendResponseMessage(this.child, {
|
||||
jsonrpc: JSONRPC_VERSION,
|
||||
id: message.id,
|
||||
result,
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
if ('id' in message && typeof message.id === 'number') {
|
||||
this.messageHandler.sendResponseMessage(this.child, {
|
||||
jsonrpc: JSONRPC_VERSION,
|
||||
id: message.id,
|
||||
error: {
|
||||
code: -32603,
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// Response
|
||||
this.messageHandler.handleMessage(
|
||||
message,
|
||||
this.pendingRequests,
|
||||
callbacks,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Authenticate
|
||||
*
|
||||
* @param methodId - Authentication method ID
|
||||
* @returns Authentication response
|
||||
*/
|
||||
async authenticate(methodId?: string): Promise<AcpResponse> {
|
||||
return this.sessionManager.authenticate(
|
||||
methodId,
|
||||
this.child,
|
||||
this.pendingRequests,
|
||||
this.nextRequestId,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create new session
|
||||
*
|
||||
* @param cwd - Working directory
|
||||
* @returns New session response
|
||||
*/
|
||||
async newSession(cwd: string = process.cwd()): Promise<AcpResponse> {
|
||||
return this.sessionManager.newSession(
|
||||
cwd,
|
||||
this.child,
|
||||
this.pendingRequests,
|
||||
this.nextRequestId,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send prompt message
|
||||
*
|
||||
* @param prompt - Prompt content
|
||||
* @returns Response
|
||||
*/
|
||||
async sendPrompt(prompt: string): Promise<AcpResponse> {
|
||||
return this.sessionManager.sendPrompt(
|
||||
prompt,
|
||||
this.child,
|
||||
this.pendingRequests,
|
||||
this.nextRequestId,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load existing session
|
||||
*
|
||||
* @param sessionId - Session ID
|
||||
* @returns Load response
|
||||
*/
|
||||
async loadSession(
|
||||
sessionId: string,
|
||||
cwdOverride?: string,
|
||||
): Promise<AcpResponse> {
|
||||
return this.sessionManager.loadSession(
|
||||
sessionId,
|
||||
this.child,
|
||||
this.pendingRequests,
|
||||
this.nextRequestId,
|
||||
cwdOverride || this.workingDir,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get session list
|
||||
*
|
||||
* @returns Session list response
|
||||
*/
|
||||
async listSessions(options?: {
|
||||
cursor?: number;
|
||||
size?: number;
|
||||
}): Promise<AcpResponse> {
|
||||
return this.sessionManager.listSessions(
|
||||
this.child,
|
||||
this.pendingRequests,
|
||||
this.nextRequestId,
|
||||
this.workingDir,
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch to specified session
|
||||
*
|
||||
* @param sessionId - Session ID
|
||||
* @returns Switch response
|
||||
*/
|
||||
async switchSession(sessionId: string): Promise<AcpResponse> {
|
||||
return this.sessionManager.switchSession(sessionId, this.nextRequestId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel current session prompt generation
|
||||
*/
|
||||
async cancelSession(): Promise<void> {
|
||||
await this.sessionManager.cancelSession(this.child);
|
||||
}
|
||||
|
||||
/**
|
||||
* Save current session
|
||||
*
|
||||
* @param tag - Save tag
|
||||
* @returns Save response
|
||||
*/
|
||||
async saveSession(tag: string): Promise<AcpResponse> {
|
||||
return this.sessionManager.saveSession(
|
||||
tag,
|
||||
this.child,
|
||||
this.pendingRequests,
|
||||
this.nextRequestId,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set approval mode
|
||||
*/
|
||||
async setMode(modeId: ApprovalModeValue): Promise<AcpResponse> {
|
||||
return this.sessionManager.setMode(
|
||||
modeId,
|
||||
this.child,
|
||||
this.pendingRequests,
|
||||
this.nextRequestId,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect
|
||||
*/
|
||||
disconnect(): void {
|
||||
if (this.child) {
|
||||
this.child.kill();
|
||||
this.child = null;
|
||||
}
|
||||
|
||||
this.pendingRequests.clear();
|
||||
this.sessionManager.reset();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if connected
|
||||
*/
|
||||
get isConnected(): boolean {
|
||||
return this.child !== null && !this.child.killed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if there is an active session
|
||||
*/
|
||||
get hasActiveSession(): boolean {
|
||||
return this.sessionManager.getCurrentSessionId() !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current session ID
|
||||
*/
|
||||
get currentSessionId(): string | null {
|
||||
return this.sessionManager.getCurrentSessionId();
|
||||
}
|
||||
}
|
||||
111
packages/vscode-ide-companion/src/services/acpFileHandler.ts
Normal file
111
packages/vscode-ide-companion/src/services/acpFileHandler.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* ACP File Operation Handler
|
||||
*
|
||||
* Responsible for handling file read and write operations in the ACP protocol
|
||||
*/
|
||||
|
||||
import { promises as fs } from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
/**
|
||||
* ACP File Operation Handler Class
|
||||
* Provides file read and write functionality according to ACP protocol specifications
|
||||
*/
|
||||
export class AcpFileHandler {
|
||||
/**
|
||||
* Handle read text file request
|
||||
*
|
||||
* @param params - File read parameters
|
||||
* @param params.path - File path
|
||||
* @param params.sessionId - Session ID
|
||||
* @param params.line - Starting line number (optional)
|
||||
* @param params.limit - Read line limit (optional)
|
||||
* @returns File content
|
||||
* @throws Error when file reading fails
|
||||
*/
|
||||
async handleReadTextFile(params: {
|
||||
path: string;
|
||||
sessionId: string;
|
||||
line: number | null;
|
||||
limit: number | null;
|
||||
}): Promise<{ content: string }> {
|
||||
console.log(`[ACP] fs/read_text_file request received for: ${params.path}`);
|
||||
console.log(`[ACP] Parameters:`, {
|
||||
line: params.line,
|
||||
limit: params.limit,
|
||||
sessionId: params.sessionId,
|
||||
});
|
||||
|
||||
try {
|
||||
const content = await fs.readFile(params.path, 'utf-8');
|
||||
console.log(
|
||||
`[ACP] Successfully read file: ${params.path} (${content.length} bytes)`,
|
||||
);
|
||||
|
||||
// Handle line offset and limit
|
||||
if (params.line !== null || params.limit !== null) {
|
||||
const lines = content.split('\n');
|
||||
const startLine = params.line || 0;
|
||||
const endLine = params.limit ? startLine + params.limit : lines.length;
|
||||
const selectedLines = lines.slice(startLine, endLine);
|
||||
const result = { content: selectedLines.join('\n') };
|
||||
console.log(`[ACP] Returning ${selectedLines.length} lines`);
|
||||
return result;
|
||||
}
|
||||
|
||||
const result = { content };
|
||||
console.log(`[ACP] Returning full file content`);
|
||||
return result;
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||
console.error(`[ACP] Failed to read file ${params.path}:`, errorMsg);
|
||||
|
||||
throw new Error(`Failed to read file '${params.path}': ${errorMsg}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle write text file request
|
||||
*
|
||||
* @param params - File write parameters
|
||||
* @param params.path - File path
|
||||
* @param params.content - File content
|
||||
* @param params.sessionId - Session ID
|
||||
* @returns null indicates success
|
||||
* @throws Error when file writing fails
|
||||
*/
|
||||
async handleWriteTextFile(params: {
|
||||
path: string;
|
||||
content: string;
|
||||
sessionId: string;
|
||||
}): Promise<null> {
|
||||
console.log(
|
||||
`[ACP] fs/write_text_file request received for: ${params.path}`,
|
||||
);
|
||||
console.log(`[ACP] Content size: ${params.content.length} bytes`);
|
||||
|
||||
try {
|
||||
// Ensure directory exists
|
||||
const dirName = path.dirname(params.path);
|
||||
console.log(`[ACP] Ensuring directory exists: ${dirName}`);
|
||||
await fs.mkdir(dirName, { recursive: true });
|
||||
|
||||
// Write file
|
||||
await fs.writeFile(params.path, params.content, 'utf-8');
|
||||
|
||||
console.log(`[ACP] Successfully wrote file: ${params.path}`);
|
||||
return null;
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||
console.error(`[ACP] Failed to write file ${params.path}:`, errorMsg);
|
||||
|
||||
throw new Error(`Failed to write file '${params.path}': ${errorMsg}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
235
packages/vscode-ide-companion/src/services/acpMessageHandler.ts
Normal file
235
packages/vscode-ide-companion/src/services/acpMessageHandler.ts
Normal file
@@ -0,0 +1,235 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* ACP Message Handler
|
||||
*
|
||||
* Responsible for receiving, parsing, and distributing messages in the ACP protocol
|
||||
*/
|
||||
|
||||
import type {
|
||||
AcpMessage,
|
||||
AcpRequest,
|
||||
AcpNotification,
|
||||
AcpResponse,
|
||||
AcpSessionUpdate,
|
||||
AcpPermissionRequest,
|
||||
} from '../types/acpTypes.js';
|
||||
import { CLIENT_METHODS } from '../constants/acpSchema.js';
|
||||
import type {
|
||||
PendingRequest,
|
||||
AcpConnectionCallbacks,
|
||||
} from '../types/connectionTypes.js';
|
||||
import { AcpFileHandler } from '../services/acpFileHandler.js';
|
||||
import type { ChildProcess } from 'child_process';
|
||||
|
||||
/**
|
||||
* ACP Message Handler Class
|
||||
* Responsible for receiving, parsing, and processing messages
|
||||
*/
|
||||
export class AcpMessageHandler {
|
||||
private fileHandler: AcpFileHandler;
|
||||
|
||||
constructor() {
|
||||
this.fileHandler = new AcpFileHandler();
|
||||
}
|
||||
|
||||
/**
|
||||
* Send response message to child process
|
||||
*
|
||||
* @param child - Child process instance
|
||||
* @param response - Response message
|
||||
*/
|
||||
sendResponseMessage(child: ChildProcess | null, response: AcpResponse): void {
|
||||
if (child?.stdin) {
|
||||
const jsonString = JSON.stringify(response);
|
||||
const lineEnding = process.platform === 'win32' ? '\r\n' : '\n';
|
||||
child.stdin.write(jsonString + lineEnding);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle received messages
|
||||
*
|
||||
* @param message - ACP message
|
||||
* @param pendingRequests - Pending requests map
|
||||
* @param callbacks - Callback functions collection
|
||||
*/
|
||||
handleMessage(
|
||||
message: AcpMessage,
|
||||
pendingRequests: Map<number, PendingRequest<unknown>>,
|
||||
callbacks: AcpConnectionCallbacks,
|
||||
): void {
|
||||
try {
|
||||
if ('method' in message) {
|
||||
// Request or notification
|
||||
this.handleIncomingRequest(message, callbacks).catch(() => {});
|
||||
} else if (
|
||||
'id' in message &&
|
||||
typeof message.id === 'number' &&
|
||||
pendingRequests.has(message.id)
|
||||
) {
|
||||
// Response
|
||||
this.handleResponse(message, pendingRequests, callbacks);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[ACP] Error handling message:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle response message
|
||||
*
|
||||
* @param message - Response message
|
||||
* @param pendingRequests - Pending requests map
|
||||
* @param callbacks - Callback functions collection
|
||||
*/
|
||||
private handleResponse(
|
||||
message: AcpMessage,
|
||||
pendingRequests: Map<number, PendingRequest<unknown>>,
|
||||
callbacks: AcpConnectionCallbacks,
|
||||
): void {
|
||||
if (!('id' in message) || typeof message.id !== 'number') {
|
||||
return;
|
||||
}
|
||||
|
||||
const pendingRequest = pendingRequests.get(message.id);
|
||||
if (!pendingRequest) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { resolve, reject, method } = pendingRequest;
|
||||
pendingRequests.delete(message.id);
|
||||
|
||||
if ('result' in message) {
|
||||
console.log(
|
||||
`[ACP] Response for ${method}:`,
|
||||
// JSON.stringify(message.result).substring(0, 200),
|
||||
message.result,
|
||||
);
|
||||
if (
|
||||
message.result &&
|
||||
typeof message.result === 'object' &&
|
||||
'stopReason' in message.result &&
|
||||
message.result.stopReason === 'end_turn'
|
||||
) {
|
||||
callbacks.onEndTurn();
|
||||
}
|
||||
resolve(message.result);
|
||||
} else if ('error' in message) {
|
||||
const errorCode = message.error?.code || 'unknown';
|
||||
const errorMsg = message.error?.message || 'Unknown ACP error';
|
||||
const errorData = message.error?.data
|
||||
? JSON.stringify(message.error.data)
|
||||
: '';
|
||||
console.error(`[ACP] Error response for ${method}:`, {
|
||||
code: errorCode,
|
||||
message: errorMsg,
|
||||
data: errorData,
|
||||
});
|
||||
reject(
|
||||
new Error(
|
||||
`${errorMsg} (code: ${errorCode})${errorData ? '\nData: ' + errorData : ''}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle incoming requests
|
||||
*
|
||||
* @param message - Request or notification message
|
||||
* @param callbacks - Callback functions collection
|
||||
* @returns Request processing result
|
||||
*/
|
||||
async handleIncomingRequest(
|
||||
message: AcpRequest | AcpNotification,
|
||||
callbacks: AcpConnectionCallbacks,
|
||||
): Promise<unknown> {
|
||||
const { method, params } = message;
|
||||
|
||||
let result = null;
|
||||
|
||||
switch (method) {
|
||||
case CLIENT_METHODS.session_update:
|
||||
console.log(
|
||||
'[ACP] >>> Processing session_update:',
|
||||
JSON.stringify(params).substring(0, 300),
|
||||
);
|
||||
callbacks.onSessionUpdate(params as AcpSessionUpdate);
|
||||
break;
|
||||
case CLIENT_METHODS.session_request_permission:
|
||||
result = await this.handlePermissionRequest(
|
||||
params as AcpPermissionRequest,
|
||||
callbacks,
|
||||
);
|
||||
break;
|
||||
case CLIENT_METHODS.fs_read_text_file:
|
||||
result = await this.fileHandler.handleReadTextFile(
|
||||
params as {
|
||||
path: string;
|
||||
sessionId: string;
|
||||
line: number | null;
|
||||
limit: number | null;
|
||||
},
|
||||
);
|
||||
break;
|
||||
case CLIENT_METHODS.fs_write_text_file:
|
||||
result = await this.fileHandler.handleWriteTextFile(
|
||||
params as { path: string; content: string; sessionId: string },
|
||||
);
|
||||
break;
|
||||
default:
|
||||
console.warn(`[ACP] Unhandled method: ${method}`);
|
||||
break;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle permission requests
|
||||
*
|
||||
* @param params - Permission request parameters
|
||||
* @param callbacks - Callback functions collection
|
||||
* @returns Permission request result
|
||||
*/
|
||||
private async handlePermissionRequest(
|
||||
params: AcpPermissionRequest,
|
||||
callbacks: AcpConnectionCallbacks,
|
||||
): Promise<{
|
||||
outcome: { outcome: string; optionId: string };
|
||||
}> {
|
||||
try {
|
||||
const response = await callbacks.onPermissionRequest(params);
|
||||
const optionId = response?.optionId;
|
||||
console.log('[ACP] Permission request:', optionId);
|
||||
// Handle cancel, deny, or allow
|
||||
let outcome: string;
|
||||
if (optionId && (optionId.includes('reject') || optionId === 'cancel')) {
|
||||
outcome = 'cancelled';
|
||||
} else {
|
||||
outcome = 'selected';
|
||||
}
|
||||
console.log('[ACP] Permission outcome:', outcome);
|
||||
|
||||
return {
|
||||
outcome: {
|
||||
outcome,
|
||||
// optionId: optionId === 'cancel' ? 'cancel' : optionId,
|
||||
optionId,
|
||||
},
|
||||
};
|
||||
} catch (_error) {
|
||||
return {
|
||||
outcome: {
|
||||
outcome: 'rejected',
|
||||
optionId: 'reject_once',
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
474
packages/vscode-ide-companion/src/services/acpSessionManager.ts
Normal file
474
packages/vscode-ide-companion/src/services/acpSessionManager.ts
Normal file
@@ -0,0 +1,474 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* ACP Session Manager
|
||||
*
|
||||
* Responsible for managing ACP protocol session operations, including initialization, authentication, session creation, and switching
|
||||
*/
|
||||
import { JSONRPC_VERSION } from '../types/acpTypes.js';
|
||||
import type {
|
||||
AcpRequest,
|
||||
AcpNotification,
|
||||
AcpResponse,
|
||||
ApprovalModeValue,
|
||||
} from '../types/acpTypes.js';
|
||||
import { AGENT_METHODS } from '../constants/acpSchema.js';
|
||||
import type { PendingRequest } from '../types/connectionTypes.js';
|
||||
import type { ChildProcess } from 'child_process';
|
||||
|
||||
/**
|
||||
* ACP Session Manager Class
|
||||
* Provides session initialization, authentication, creation, loading, and switching functionality
|
||||
*/
|
||||
export class AcpSessionManager {
|
||||
private sessionId: string | null = null;
|
||||
private isInitialized = false;
|
||||
|
||||
/**
|
||||
* Send request to ACP server
|
||||
*
|
||||
* @param method - Request method name
|
||||
* @param params - Request parameters
|
||||
* @param child - Child process instance
|
||||
* @param pendingRequests - Pending requests map
|
||||
* @param nextRequestId - Request ID counter
|
||||
* @returns Request response
|
||||
*/
|
||||
private sendRequest<T = unknown>(
|
||||
method: string,
|
||||
params: Record<string, unknown> | undefined,
|
||||
child: ChildProcess | null,
|
||||
pendingRequests: Map<number, PendingRequest<unknown>>,
|
||||
nextRequestId: { value: number },
|
||||
): Promise<T> {
|
||||
const id = nextRequestId.value++;
|
||||
const message: AcpRequest = {
|
||||
jsonrpc: JSONRPC_VERSION,
|
||||
id,
|
||||
method,
|
||||
...(params && { params }),
|
||||
};
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeoutDuration =
|
||||
method === AGENT_METHODS.session_prompt ? 120000 : 60000;
|
||||
|
||||
const timeoutId = setTimeout(() => {
|
||||
pendingRequests.delete(id);
|
||||
reject(new Error(`Request ${method} timed out`));
|
||||
}, timeoutDuration);
|
||||
|
||||
const pendingRequest: PendingRequest<T> = {
|
||||
resolve: (value: T) => {
|
||||
clearTimeout(timeoutId);
|
||||
resolve(value);
|
||||
},
|
||||
reject: (error: Error) => {
|
||||
clearTimeout(timeoutId);
|
||||
reject(error);
|
||||
},
|
||||
timeoutId,
|
||||
method,
|
||||
};
|
||||
|
||||
pendingRequests.set(id, pendingRequest as PendingRequest<unknown>);
|
||||
this.sendMessage(message, child);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Send message to child process
|
||||
*
|
||||
* @param message - Request or notification message
|
||||
* @param child - Child process instance
|
||||
*/
|
||||
private sendMessage(
|
||||
message: AcpRequest | AcpNotification,
|
||||
child: ChildProcess | null,
|
||||
): void {
|
||||
if (child?.stdin) {
|
||||
const jsonString = JSON.stringify(message);
|
||||
const lineEnding = process.platform === 'win32' ? '\r\n' : '\n';
|
||||
child.stdin.write(jsonString + lineEnding);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize ACP protocol connection
|
||||
*
|
||||
* @param child - Child process instance
|
||||
* @param pendingRequests - Pending requests map
|
||||
* @param nextRequestId - Request ID counter
|
||||
* @returns Initialization response
|
||||
*/
|
||||
async initialize(
|
||||
child: ChildProcess | null,
|
||||
pendingRequests: Map<number, PendingRequest<unknown>>,
|
||||
nextRequestId: { value: number },
|
||||
): Promise<AcpResponse> {
|
||||
const initializeParams = {
|
||||
protocolVersion: 1,
|
||||
clientCapabilities: {
|
||||
fs: {
|
||||
readTextFile: true,
|
||||
writeTextFile: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
console.log('[ACP] Sending initialize request...');
|
||||
const response = await this.sendRequest<AcpResponse>(
|
||||
AGENT_METHODS.initialize,
|
||||
initializeParams,
|
||||
child,
|
||||
pendingRequests,
|
||||
nextRequestId,
|
||||
);
|
||||
this.isInitialized = true;
|
||||
|
||||
console.log('[ACP] Initialize successful');
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform authentication
|
||||
*
|
||||
* @param methodId - Authentication method ID
|
||||
* @param child - Child process instance
|
||||
* @param pendingRequests - Pending requests map
|
||||
* @param nextRequestId - Request ID counter
|
||||
* @returns Authentication response
|
||||
*/
|
||||
async authenticate(
|
||||
methodId: string | undefined,
|
||||
child: ChildProcess | null,
|
||||
pendingRequests: Map<number, PendingRequest<unknown>>,
|
||||
nextRequestId: { value: number },
|
||||
): Promise<AcpResponse> {
|
||||
const authMethodId = methodId || 'default';
|
||||
console.log(
|
||||
'[ACP] Sending authenticate request with methodId:',
|
||||
authMethodId,
|
||||
);
|
||||
const response = await this.sendRequest<AcpResponse>(
|
||||
AGENT_METHODS.authenticate,
|
||||
{
|
||||
methodId: authMethodId,
|
||||
},
|
||||
child,
|
||||
pendingRequests,
|
||||
nextRequestId,
|
||||
);
|
||||
console.log('[ACP] Authenticate successful');
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create new session
|
||||
*
|
||||
* @param cwd - Working directory
|
||||
* @param child - Child process instance
|
||||
* @param pendingRequests - Pending requests map
|
||||
* @param nextRequestId - Request ID counter
|
||||
* @returns New session response
|
||||
*/
|
||||
async newSession(
|
||||
cwd: string,
|
||||
child: ChildProcess | null,
|
||||
pendingRequests: Map<number, PendingRequest<unknown>>,
|
||||
nextRequestId: { value: number },
|
||||
): Promise<AcpResponse> {
|
||||
console.log('[ACP] Sending session/new request with cwd:', cwd);
|
||||
const response = await this.sendRequest<
|
||||
AcpResponse & { sessionId?: string }
|
||||
>(
|
||||
AGENT_METHODS.session_new,
|
||||
{
|
||||
cwd,
|
||||
mcpServers: [],
|
||||
},
|
||||
child,
|
||||
pendingRequests,
|
||||
nextRequestId,
|
||||
);
|
||||
|
||||
this.sessionId = (response && response.sessionId) || null;
|
||||
console.log('[ACP] Session created with ID:', this.sessionId);
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send prompt message
|
||||
*
|
||||
* @param prompt - Prompt content
|
||||
* @param child - Child process instance
|
||||
* @param pendingRequests - Pending requests map
|
||||
* @param nextRequestId - Request ID counter
|
||||
* @returns Response
|
||||
* @throws Error when there is no active session
|
||||
*/
|
||||
async sendPrompt(
|
||||
prompt: string,
|
||||
child: ChildProcess | null,
|
||||
pendingRequests: Map<number, PendingRequest<unknown>>,
|
||||
nextRequestId: { value: number },
|
||||
): Promise<AcpResponse> {
|
||||
if (!this.sessionId) {
|
||||
throw new Error('No active ACP session');
|
||||
}
|
||||
|
||||
return await this.sendRequest(
|
||||
AGENT_METHODS.session_prompt,
|
||||
{
|
||||
sessionId: this.sessionId,
|
||||
prompt: [{ type: 'text', text: prompt }],
|
||||
},
|
||||
child,
|
||||
pendingRequests,
|
||||
nextRequestId,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load existing session
|
||||
*
|
||||
* @param sessionId - Session ID
|
||||
* @param child - Child process instance
|
||||
* @param pendingRequests - Pending requests map
|
||||
* @param nextRequestId - Request ID counter
|
||||
* @returns Load response
|
||||
*/
|
||||
async loadSession(
|
||||
sessionId: string,
|
||||
child: ChildProcess | null,
|
||||
pendingRequests: Map<number, PendingRequest<unknown>>,
|
||||
nextRequestId: { value: number },
|
||||
cwd: string = process.cwd(),
|
||||
): Promise<AcpResponse> {
|
||||
console.log('[ACP] Sending session/load request for session:', sessionId);
|
||||
console.log('[ACP] Request parameters:', {
|
||||
sessionId,
|
||||
cwd,
|
||||
mcpServers: [],
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await this.sendRequest<AcpResponse>(
|
||||
AGENT_METHODS.session_load,
|
||||
{
|
||||
sessionId,
|
||||
cwd,
|
||||
mcpServers: [],
|
||||
},
|
||||
child,
|
||||
pendingRequests,
|
||||
nextRequestId,
|
||||
);
|
||||
|
||||
console.log(
|
||||
'[ACP] Session load response:',
|
||||
JSON.stringify(response).substring(0, 500),
|
||||
);
|
||||
|
||||
// Check if response contains an error
|
||||
if (response && response.error) {
|
||||
console.error('[ACP] Session load returned error:', response.error);
|
||||
} else {
|
||||
console.log('[ACP] Session load succeeded');
|
||||
// session/load returns null on success per schema; update local sessionId
|
||||
// so subsequent prompts use the loaded session.
|
||||
this.sessionId = sessionId;
|
||||
}
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'[ACP] Session load request failed with exception:',
|
||||
error instanceof Error ? error.message : String(error),
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get session list
|
||||
*
|
||||
* @param child - Child process instance
|
||||
* @param pendingRequests - Pending requests map
|
||||
* @param nextRequestId - Request ID counter
|
||||
* @returns Session list response
|
||||
*/
|
||||
async listSessions(
|
||||
child: ChildProcess | null,
|
||||
pendingRequests: Map<number, PendingRequest<unknown>>,
|
||||
nextRequestId: { value: number },
|
||||
cwd: string = process.cwd(),
|
||||
options?: { cursor?: number; size?: number },
|
||||
): Promise<AcpResponse> {
|
||||
console.log('[ACP] Requesting session list...');
|
||||
try {
|
||||
// session/list requires cwd in params per ACP schema
|
||||
const params: Record<string, unknown> = { cwd };
|
||||
if (options?.cursor !== undefined) {
|
||||
params.cursor = options.cursor;
|
||||
}
|
||||
if (options?.size !== undefined) {
|
||||
params.size = options.size;
|
||||
}
|
||||
|
||||
const response = await this.sendRequest<AcpResponse>(
|
||||
AGENT_METHODS.session_list,
|
||||
params,
|
||||
child,
|
||||
pendingRequests,
|
||||
nextRequestId,
|
||||
);
|
||||
console.log(
|
||||
'[ACP] Session list response:',
|
||||
JSON.stringify(response).substring(0, 200),
|
||||
);
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.error('[ACP] Failed to get session list:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set approval mode for current session (ACP session/set_mode)
|
||||
*
|
||||
* @param modeId - Approval mode value
|
||||
*/
|
||||
async setMode(
|
||||
modeId: ApprovalModeValue,
|
||||
child: ChildProcess | null,
|
||||
pendingRequests: Map<number, PendingRequest<unknown>>,
|
||||
nextRequestId: { value: number },
|
||||
): Promise<AcpResponse> {
|
||||
if (!this.sessionId) {
|
||||
throw new Error('No active ACP session');
|
||||
}
|
||||
console.log('[ACP] Sending session/set_mode:', modeId);
|
||||
const res = await this.sendRequest<AcpResponse>(
|
||||
AGENT_METHODS.session_set_mode,
|
||||
{ sessionId: this.sessionId, modeId },
|
||||
child,
|
||||
pendingRequests,
|
||||
nextRequestId,
|
||||
);
|
||||
console.log('[ACP] set_mode response:', res);
|
||||
return res;
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch to specified session
|
||||
*
|
||||
* @param sessionId - Session ID
|
||||
* @param nextRequestId - Request ID counter
|
||||
* @returns Switch response
|
||||
*/
|
||||
async switchSession(
|
||||
sessionId: string,
|
||||
nextRequestId: { value: number },
|
||||
): Promise<AcpResponse> {
|
||||
console.log('[ACP] Switching to session:', sessionId);
|
||||
this.sessionId = sessionId;
|
||||
|
||||
const mockResponse: AcpResponse = {
|
||||
jsonrpc: JSONRPC_VERSION,
|
||||
id: nextRequestId.value++,
|
||||
result: { sessionId },
|
||||
};
|
||||
console.log(
|
||||
'[ACP] Session ID updated locally (switch not supported by CLI)',
|
||||
);
|
||||
return mockResponse;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel prompt generation for current session
|
||||
*
|
||||
* @param child - Child process instance
|
||||
*/
|
||||
async cancelSession(child: ChildProcess | null): Promise<void> {
|
||||
if (!this.sessionId) {
|
||||
console.warn('[ACP] No active session to cancel');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[ACP] Cancelling session:', this.sessionId);
|
||||
|
||||
const cancelParams = {
|
||||
sessionId: this.sessionId,
|
||||
};
|
||||
|
||||
const message: AcpNotification = {
|
||||
jsonrpc: JSONRPC_VERSION,
|
||||
method: AGENT_METHODS.session_cancel,
|
||||
params: cancelParams,
|
||||
};
|
||||
|
||||
this.sendMessage(message, child);
|
||||
console.log('[ACP] Cancel notification sent');
|
||||
}
|
||||
|
||||
/**
|
||||
* Save current session
|
||||
*
|
||||
* @param tag - Save tag
|
||||
* @param child - Child process instance
|
||||
* @param pendingRequests - Pending requests map
|
||||
* @param nextRequestId - Request ID counter
|
||||
* @returns Save response
|
||||
*/
|
||||
async saveSession(
|
||||
tag: string,
|
||||
child: ChildProcess | null,
|
||||
pendingRequests: Map<number, PendingRequest<unknown>>,
|
||||
nextRequestId: { value: number },
|
||||
): Promise<AcpResponse> {
|
||||
if (!this.sessionId) {
|
||||
throw new Error('No active ACP session');
|
||||
}
|
||||
|
||||
console.log('[ACP] Saving session with tag:', tag);
|
||||
const response = await this.sendRequest<AcpResponse>(
|
||||
AGENT_METHODS.session_save,
|
||||
{
|
||||
sessionId: this.sessionId,
|
||||
tag,
|
||||
},
|
||||
child,
|
||||
pendingRequests,
|
||||
nextRequestId,
|
||||
);
|
||||
console.log('[ACP] Session save response:', response);
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset session manager state
|
||||
*/
|
||||
reset(): void {
|
||||
this.sessionId = null;
|
||||
this.isInitialized = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current session ID
|
||||
*/
|
||||
getCurrentSessionId(): string | null {
|
||||
return this.sessionId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if initialized
|
||||
*/
|
||||
getIsInitialized(): boolean {
|
||||
return this.isInitialized;
|
||||
}
|
||||
}
|
||||
215
packages/vscode-ide-companion/src/services/authStateManager.ts
Normal file
215
packages/vscode-ide-companion/src/services/authStateManager.ts
Normal file
@@ -0,0 +1,215 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type * as vscode from 'vscode';
|
||||
|
||||
interface AuthState {
|
||||
isAuthenticated: boolean;
|
||||
authMethod: string;
|
||||
timestamp: number;
|
||||
workingDir?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Manages authentication state caching to avoid repeated logins
|
||||
*/
|
||||
export class AuthStateManager {
|
||||
private static instance: AuthStateManager | null = null;
|
||||
private static context: vscode.ExtensionContext | null = null;
|
||||
private static readonly AUTH_STATE_KEY = 'qwen.authState';
|
||||
private static readonly AUTH_CACHE_DURATION = 24 * 60 * 60 * 1000; // 24 hours
|
||||
private constructor() {}
|
||||
|
||||
/**
|
||||
* Get singleton instance of AuthStateManager
|
||||
*/
|
||||
static getInstance(context?: vscode.ExtensionContext): AuthStateManager {
|
||||
if (!AuthStateManager.instance) {
|
||||
AuthStateManager.instance = new AuthStateManager();
|
||||
}
|
||||
|
||||
// If a context is provided, update the static context
|
||||
if (context) {
|
||||
AuthStateManager.context = context;
|
||||
}
|
||||
|
||||
return AuthStateManager.instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if there's a valid cached authentication
|
||||
*/
|
||||
async hasValidAuth(workingDir: string, authMethod: string): Promise<boolean> {
|
||||
const state = await this.getAuthState();
|
||||
|
||||
if (!state) {
|
||||
console.log('[AuthStateManager] No cached auth state found');
|
||||
return false;
|
||||
}
|
||||
|
||||
console.log('[AuthStateManager] Found cached auth state:', {
|
||||
workingDir: state.workingDir,
|
||||
authMethod: state.authMethod,
|
||||
timestamp: new Date(state.timestamp).toISOString(),
|
||||
isAuthenticated: state.isAuthenticated,
|
||||
});
|
||||
console.log('[AuthStateManager] Checking against:', {
|
||||
workingDir,
|
||||
authMethod,
|
||||
});
|
||||
|
||||
// Check if auth is still valid (within cache duration)
|
||||
const now = Date.now();
|
||||
const isExpired =
|
||||
now - state.timestamp > AuthStateManager.AUTH_CACHE_DURATION;
|
||||
|
||||
if (isExpired) {
|
||||
console.log('[AuthStateManager] Cached auth expired');
|
||||
console.log(
|
||||
'[AuthStateManager] Cache age:',
|
||||
Math.floor((now - state.timestamp) / 1000 / 60),
|
||||
'minutes',
|
||||
);
|
||||
await this.clearAuthState();
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if it's for the same working directory and auth method
|
||||
const isSameContext =
|
||||
state.workingDir === workingDir && state.authMethod === authMethod;
|
||||
|
||||
if (!isSameContext) {
|
||||
console.log('[AuthStateManager] Working dir or auth method changed');
|
||||
console.log('[AuthStateManager] Cached workingDir:', state.workingDir);
|
||||
console.log('[AuthStateManager] Current workingDir:', workingDir);
|
||||
console.log('[AuthStateManager] Cached authMethod:', state.authMethod);
|
||||
console.log('[AuthStateManager] Current authMethod:', authMethod);
|
||||
return false;
|
||||
}
|
||||
|
||||
console.log('[AuthStateManager] Valid cached auth found');
|
||||
return state.isAuthenticated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Force check auth state without clearing cache
|
||||
* This is useful for debugging to see what's actually cached
|
||||
*/
|
||||
async debugAuthState(): Promise<void> {
|
||||
const state = await this.getAuthState();
|
||||
console.log('[AuthStateManager] DEBUG - Current auth state:', state);
|
||||
|
||||
if (state) {
|
||||
const now = Date.now();
|
||||
const age = Math.floor((now - state.timestamp) / 1000 / 60);
|
||||
const isExpired =
|
||||
now - state.timestamp > AuthStateManager.AUTH_CACHE_DURATION;
|
||||
|
||||
console.log('[AuthStateManager] DEBUG - Auth state age:', age, 'minutes');
|
||||
console.log('[AuthStateManager] DEBUG - Auth state expired:', isExpired);
|
||||
console.log(
|
||||
'[AuthStateManager] DEBUG - Auth state valid:',
|
||||
state.isAuthenticated,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save successful authentication state
|
||||
*/
|
||||
async saveAuthState(workingDir: string, authMethod: string): Promise<void> {
|
||||
// Ensure we have a valid context
|
||||
if (!AuthStateManager.context) {
|
||||
throw new Error(
|
||||
'[AuthStateManager] No context available for saving auth state',
|
||||
);
|
||||
}
|
||||
|
||||
const state: AuthState = {
|
||||
isAuthenticated: true,
|
||||
authMethod,
|
||||
workingDir,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
console.log('[AuthStateManager] Saving auth state:', {
|
||||
workingDir,
|
||||
authMethod,
|
||||
timestamp: new Date(state.timestamp).toISOString(),
|
||||
});
|
||||
|
||||
await AuthStateManager.context.globalState.update(
|
||||
AuthStateManager.AUTH_STATE_KEY,
|
||||
state,
|
||||
);
|
||||
console.log('[AuthStateManager] Auth state saved');
|
||||
|
||||
// Verify the state was saved correctly
|
||||
const savedState = await this.getAuthState();
|
||||
console.log('[AuthStateManager] Verified saved state:', savedState);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear authentication state
|
||||
*/
|
||||
async clearAuthState(): Promise<void> {
|
||||
// Ensure we have a valid context
|
||||
if (!AuthStateManager.context) {
|
||||
throw new Error(
|
||||
'[AuthStateManager] No context available for clearing auth state',
|
||||
);
|
||||
}
|
||||
|
||||
console.log('[AuthStateManager] Clearing auth state');
|
||||
const currentState = await this.getAuthState();
|
||||
console.log(
|
||||
'[AuthStateManager] Current state before clearing:',
|
||||
currentState,
|
||||
);
|
||||
|
||||
await AuthStateManager.context.globalState.update(
|
||||
AuthStateManager.AUTH_STATE_KEY,
|
||||
undefined,
|
||||
);
|
||||
console.log('[AuthStateManager] Auth state cleared');
|
||||
|
||||
// Verify the state was cleared
|
||||
const newState = await this.getAuthState();
|
||||
console.log('[AuthStateManager] State after clearing:', newState);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current auth state
|
||||
*/
|
||||
private async getAuthState(): Promise<AuthState | undefined> {
|
||||
// Ensure we have a valid context
|
||||
if (!AuthStateManager.context) {
|
||||
console.log(
|
||||
'[AuthStateManager] No context available for getting auth state',
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const a = AuthStateManager.context.globalState.get<AuthState>(
|
||||
AuthStateManager.AUTH_STATE_KEY,
|
||||
);
|
||||
console.log('[AuthStateManager] Auth state:', a);
|
||||
return a;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get auth state info for debugging
|
||||
*/
|
||||
async getAuthInfo(): Promise<string> {
|
||||
const state = await this.getAuthState();
|
||||
if (!state) {
|
||||
return 'No cached auth';
|
||||
}
|
||||
|
||||
const age = Math.floor((Date.now() - state.timestamp) / 1000 / 60);
|
||||
return `Auth cached ${age}m ago, method: ${state.authMethod}`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type * as vscode from 'vscode';
|
||||
import type { ChatMessage } from './qwenAgentManager.js';
|
||||
|
||||
export interface Conversation {
|
||||
id: string;
|
||||
title: string;
|
||||
messages: ChatMessage[];
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
export class ConversationStore {
|
||||
private context: vscode.ExtensionContext;
|
||||
private currentConversationId: string | null = null;
|
||||
|
||||
constructor(context: vscode.ExtensionContext) {
|
||||
this.context = context;
|
||||
}
|
||||
|
||||
async createConversation(title: string = 'New Chat'): Promise<Conversation> {
|
||||
const conversation: Conversation = {
|
||||
id: `conv_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
|
||||
title,
|
||||
messages: [],
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
|
||||
const conversations = await this.getAllConversations();
|
||||
conversations.push(conversation);
|
||||
await this.context.globalState.update('conversations', conversations);
|
||||
|
||||
this.currentConversationId = conversation.id;
|
||||
return conversation;
|
||||
}
|
||||
|
||||
async getAllConversations(): Promise<Conversation[]> {
|
||||
return this.context.globalState.get<Conversation[]>('conversations', []);
|
||||
}
|
||||
|
||||
async getConversation(id: string): Promise<Conversation | null> {
|
||||
const conversations = await this.getAllConversations();
|
||||
return conversations.find((c) => c.id === id) || null;
|
||||
}
|
||||
|
||||
async addMessage(
|
||||
conversationId: string,
|
||||
message: ChatMessage,
|
||||
): Promise<void> {
|
||||
const conversations = await this.getAllConversations();
|
||||
const conversation = conversations.find((c) => c.id === conversationId);
|
||||
|
||||
if (conversation) {
|
||||
conversation.messages.push(message);
|
||||
conversation.updatedAt = Date.now();
|
||||
await this.context.globalState.update('conversations', conversations);
|
||||
}
|
||||
}
|
||||
|
||||
async deleteConversation(id: string): Promise<void> {
|
||||
const conversations = await this.getAllConversations();
|
||||
const filtered = conversations.filter((c) => c.id !== id);
|
||||
await this.context.globalState.update('conversations', filtered);
|
||||
|
||||
if (this.currentConversationId === id) {
|
||||
this.currentConversationId = null;
|
||||
}
|
||||
}
|
||||
|
||||
getCurrentConversationId(): string | null {
|
||||
return this.currentConversationId;
|
||||
}
|
||||
|
||||
setCurrentConversationId(id: string): void {
|
||||
this.currentConversationId = id;
|
||||
}
|
||||
}
|
||||
1411
packages/vscode-ide-companion/src/services/qwenAgentManager.ts
Normal file
1411
packages/vscode-ide-companion/src/services/qwenAgentManager.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,260 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* Qwen Connection Handler
|
||||
*
|
||||
* Handles Qwen Agent connection establishment, authentication, and session creation
|
||||
*/
|
||||
|
||||
import * as vscode from 'vscode';
|
||||
import type { AcpConnection } from './acpConnection.js';
|
||||
import type { QwenSessionReader } from '../services/qwenSessionReader.js';
|
||||
import type { AuthStateManager } from '../services/authStateManager.js';
|
||||
import {
|
||||
CliVersionManager,
|
||||
MIN_CLI_VERSION_FOR_SESSION_METHODS,
|
||||
} from '../cli/cliVersionManager.js';
|
||||
import { CliContextManager } from '../cli/cliContextManager.js';
|
||||
import { authMethod } from '../types/acpTypes.js';
|
||||
|
||||
/**
|
||||
* Qwen Connection Handler class
|
||||
* Handles connection, authentication, and session initialization
|
||||
*/
|
||||
export class QwenConnectionHandler {
|
||||
/**
|
||||
* Connect to Qwen service and establish session
|
||||
*
|
||||
* @param connection - ACP connection instance
|
||||
* @param sessionReader - Session reader instance
|
||||
* @param workingDir - Working directory
|
||||
* @param authStateManager - Authentication state manager (optional)
|
||||
* @param cliPath - CLI path (optional, if provided will override the path in configuration)
|
||||
*/
|
||||
async connect(
|
||||
connection: AcpConnection,
|
||||
sessionReader: QwenSessionReader,
|
||||
workingDir: string,
|
||||
authStateManager?: AuthStateManager,
|
||||
cliPath?: string,
|
||||
): Promise<void> {
|
||||
const connectId = Date.now();
|
||||
console.log(`[QwenAgentManager] 🚀 CONNECT() CALLED - ID: ${connectId}`);
|
||||
|
||||
// Check CLI version and features
|
||||
const cliVersionManager = CliVersionManager.getInstance();
|
||||
const versionInfo = await cliVersionManager.detectCliVersion();
|
||||
console.log('[QwenAgentManager] CLI version info:', versionInfo);
|
||||
|
||||
// Store CLI context
|
||||
const cliContextManager = CliContextManager.getInstance();
|
||||
cliContextManager.setCurrentVersionInfo(versionInfo);
|
||||
|
||||
// Show warning if CLI version is below minimum requirement
|
||||
if (!versionInfo.isSupported) {
|
||||
// Wait to determine release version number
|
||||
vscode.window.showWarningMessage(
|
||||
`Qwen Code CLI version ${versionInfo.version} is below the minimum required version. Some features may not work properly. Please upgrade to version ${MIN_CLI_VERSION_FOR_SESSION_METHODS} or later.`,
|
||||
);
|
||||
}
|
||||
|
||||
const config = vscode.workspace.getConfiguration('qwenCode');
|
||||
// Use the provided CLI path if available, otherwise use the configured path
|
||||
const effectiveCliPath =
|
||||
cliPath || config.get<string>('qwen.cliPath', 'qwen');
|
||||
|
||||
// Build extra CLI arguments (only essential parameters)
|
||||
const extraArgs: string[] = [];
|
||||
|
||||
await connection.connect(effectiveCliPath, workingDir, extraArgs);
|
||||
|
||||
// Check if we have valid cached authentication
|
||||
if (authStateManager) {
|
||||
console.log('[QwenAgentManager] Checking for cached authentication...');
|
||||
console.log('[QwenAgentManager] Working dir:', workingDir);
|
||||
console.log('[QwenAgentManager] Auth method:', authMethod);
|
||||
|
||||
const hasValidAuth = await authStateManager.hasValidAuth(
|
||||
workingDir,
|
||||
authMethod,
|
||||
);
|
||||
console.log('[QwenAgentManager] Has valid auth:', hasValidAuth);
|
||||
} else {
|
||||
console.log('[QwenAgentManager] No authStateManager provided');
|
||||
}
|
||||
|
||||
// Try to restore existing session or create new session
|
||||
// Note: Auto-restore on connect is disabled to avoid surprising loads
|
||||
// when user opens a "New Chat" tab. Restoration is now an explicit action
|
||||
// (session selector → session/load) or handled by higher-level flows.
|
||||
const sessionRestored = false;
|
||||
|
||||
// Create new session if unable to restore
|
||||
if (!sessionRestored) {
|
||||
console.log(
|
||||
'[QwenAgentManager] no sessionRestored, Creating new session...',
|
||||
);
|
||||
|
||||
// Check if we have valid cached authentication
|
||||
let hasValidAuth = false;
|
||||
if (authStateManager) {
|
||||
hasValidAuth = await authStateManager.hasValidAuth(
|
||||
workingDir,
|
||||
authMethod,
|
||||
);
|
||||
}
|
||||
|
||||
// Only authenticate if we don't have valid cached auth
|
||||
if (!hasValidAuth) {
|
||||
console.log(
|
||||
'[QwenAgentManager] Authenticating before creating session...',
|
||||
);
|
||||
try {
|
||||
await connection.authenticate(authMethod);
|
||||
console.log('[QwenAgentManager] Authentication successful');
|
||||
|
||||
// Save auth state
|
||||
if (authStateManager) {
|
||||
console.log(
|
||||
'[QwenAgentManager] Saving auth state after successful authentication',
|
||||
);
|
||||
console.log('[QwenAgentManager] Working dir for save:', workingDir);
|
||||
console.log('[QwenAgentManager] Auth method for save:', authMethod);
|
||||
await authStateManager.saveAuthState(workingDir, authMethod);
|
||||
console.log('[QwenAgentManager] Auth state save completed');
|
||||
}
|
||||
} catch (authError) {
|
||||
console.error('[QwenAgentManager] Authentication failed:', authError);
|
||||
// Clear potentially invalid cache
|
||||
if (authStateManager) {
|
||||
console.log(
|
||||
'[QwenAgentManager] Clearing auth cache due to authentication failure',
|
||||
);
|
||||
await authStateManager.clearAuthState();
|
||||
}
|
||||
throw authError;
|
||||
}
|
||||
} else {
|
||||
console.log(
|
||||
'[QwenAgentManager] Skipping authentication - using valid cached auth',
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
console.log(
|
||||
'[QwenAgentManager] Creating new session after authentication...',
|
||||
);
|
||||
await this.newSessionWithRetry(
|
||||
connection,
|
||||
workingDir,
|
||||
3,
|
||||
authMethod,
|
||||
authStateManager,
|
||||
);
|
||||
console.log('[QwenAgentManager] New session created successfully');
|
||||
|
||||
// Ensure auth state is saved (prevent repeated authentication)
|
||||
if (authStateManager) {
|
||||
console.log(
|
||||
'[QwenAgentManager] Saving auth state after successful session creation',
|
||||
);
|
||||
await authStateManager.saveAuthState(workingDir, authMethod);
|
||||
}
|
||||
} catch (sessionError) {
|
||||
console.log(`\n⚠️ [SESSION FAILED] newSessionWithRetry threw error\n`);
|
||||
console.log(`[QwenAgentManager] Error details:`, sessionError);
|
||||
|
||||
// Clear cache
|
||||
if (authStateManager) {
|
||||
console.log('[QwenAgentManager] Clearing auth cache due to failure');
|
||||
await authStateManager.clearAuthState();
|
||||
}
|
||||
|
||||
throw sessionError;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\n========================================`);
|
||||
console.log(`[QwenAgentManager] ✅ CONNECT() COMPLETED SUCCESSFULLY`);
|
||||
console.log(`========================================\n`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create new session (with retry)
|
||||
*
|
||||
* @param connection - ACP connection instance
|
||||
* @param workingDir - Working directory
|
||||
* @param maxRetries - Maximum number of retries
|
||||
*/
|
||||
private async newSessionWithRetry(
|
||||
connection: AcpConnection,
|
||||
workingDir: string,
|
||||
maxRetries: number,
|
||||
authMethod: string,
|
||||
authStateManager?: AuthStateManager,
|
||||
): Promise<void> {
|
||||
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
||||
try {
|
||||
console.log(
|
||||
`[QwenAgentManager] Creating session (attempt ${attempt}/${maxRetries})...`,
|
||||
);
|
||||
await connection.newSession(workingDir);
|
||||
console.log('[QwenAgentManager] Session created successfully');
|
||||
return;
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
console.error(
|
||||
`[QwenAgentManager] Session creation attempt ${attempt} failed:`,
|
||||
errorMessage,
|
||||
);
|
||||
|
||||
// If Qwen reports that authentication is required, try to
|
||||
// authenticate on-the-fly once and retry without waiting.
|
||||
const requiresAuth =
|
||||
errorMessage.includes('Authentication required') ||
|
||||
errorMessage.includes('(code: -32000)');
|
||||
if (requiresAuth) {
|
||||
console.log(
|
||||
'[QwenAgentManager] Qwen requires authentication. Authenticating and retrying session/new...',
|
||||
);
|
||||
try {
|
||||
await connection.authenticate(authMethod);
|
||||
if (authStateManager) {
|
||||
await authStateManager.saveAuthState(workingDir, authMethod);
|
||||
}
|
||||
// Retry immediately after successful auth
|
||||
await connection.newSession(workingDir);
|
||||
console.log(
|
||||
'[QwenAgentManager] Session created successfully after auth',
|
||||
);
|
||||
return;
|
||||
} catch (authErr) {
|
||||
console.error(
|
||||
'[QwenAgentManager] Re-authentication failed:',
|
||||
authErr,
|
||||
);
|
||||
if (authStateManager) {
|
||||
await authStateManager.clearAuthState();
|
||||
}
|
||||
// Fall through to retry logic below
|
||||
}
|
||||
}
|
||||
|
||||
if (attempt === maxRetries) {
|
||||
throw new Error(
|
||||
`Session creation failed after ${maxRetries} attempts: ${errorMessage}`,
|
||||
);
|
||||
}
|
||||
|
||||
const delay = Math.min(1000 * Math.pow(2, attempt - 1), 5000);
|
||||
console.log(`[QwenAgentManager] Retrying in ${delay}ms...`);
|
||||
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
336
packages/vscode-ide-companion/src/services/qwenSessionManager.ts
Normal file
336
packages/vscode-ide-companion/src/services/qwenSessionManager.ts
Normal file
@@ -0,0 +1,336 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
import * as crypto from 'crypto';
|
||||
import type { QwenSession, QwenMessage } from './qwenSessionReader.js';
|
||||
|
||||
/**
|
||||
* Qwen Session Manager
|
||||
*
|
||||
* This service provides direct filesystem access to save and load sessions
|
||||
* without relying on the CLI's ACP session/save method.
|
||||
*
|
||||
* Note: This is primarily used as a fallback mechanism when ACP methods are
|
||||
* unavailable or fail. In normal operation, ACP session/list and session/load
|
||||
* should be preferred for consistency with the CLI.
|
||||
*/
|
||||
export class QwenSessionManager {
|
||||
private qwenDir: string;
|
||||
|
||||
constructor() {
|
||||
this.qwenDir = path.join(os.homedir(), '.qwen');
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate project hash (same as CLI)
|
||||
* Qwen CLI uses SHA256 hash of the project path
|
||||
*/
|
||||
private getProjectHash(workingDir: string): string {
|
||||
return crypto.createHash('sha256').update(workingDir).digest('hex');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the session directory for a project
|
||||
*/
|
||||
private getSessionDir(workingDir: string): string {
|
||||
const projectHash = this.getProjectHash(workingDir);
|
||||
return path.join(this.qwenDir, 'tmp', projectHash, 'chats');
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a new session ID
|
||||
*/
|
||||
private generateSessionId(): string {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
|
||||
/**
|
||||
* Save current conversation as a checkpoint (matching CLI's /chat save format)
|
||||
* Creates checkpoint with BOTH conversationId and sessionId as tags for compatibility
|
||||
*
|
||||
* @param messages - Current conversation messages
|
||||
* @param conversationId - Conversation ID (from VSCode extension)
|
||||
* @param sessionId - Session ID (from CLI tmp session file, optional)
|
||||
* @param workingDir - Current working directory
|
||||
* @returns Checkpoint tag
|
||||
*/
|
||||
async saveCheckpoint(
|
||||
messages: QwenMessage[],
|
||||
conversationId: string,
|
||||
workingDir: string,
|
||||
sessionId?: string,
|
||||
): Promise<string> {
|
||||
try {
|
||||
console.log('[QwenSessionManager] ===== SAVEPOINT START =====');
|
||||
console.log('[QwenSessionManager] Conversation ID:', conversationId);
|
||||
console.log(
|
||||
'[QwenSessionManager] Session ID:',
|
||||
sessionId || 'not provided',
|
||||
);
|
||||
console.log('[QwenSessionManager] Working dir:', workingDir);
|
||||
console.log('[QwenSessionManager] Message count:', messages.length);
|
||||
|
||||
// Get project directory (parent of chats directory)
|
||||
const projectHash = this.getProjectHash(workingDir);
|
||||
console.log('[QwenSessionManager] Project hash:', projectHash);
|
||||
|
||||
const projectDir = path.join(this.qwenDir, 'tmp', projectHash);
|
||||
console.log('[QwenSessionManager] Project dir:', projectDir);
|
||||
|
||||
if (!fs.existsSync(projectDir)) {
|
||||
console.log('[QwenSessionManager] Creating project directory...');
|
||||
fs.mkdirSync(projectDir, { recursive: true });
|
||||
console.log('[QwenSessionManager] Directory created');
|
||||
} else {
|
||||
console.log('[QwenSessionManager] Project directory already exists');
|
||||
}
|
||||
|
||||
// Convert messages to checkpoint format (Gemini-style messages)
|
||||
console.log(
|
||||
'[QwenSessionManager] Converting messages to checkpoint format...',
|
||||
);
|
||||
const checkpointMessages = messages.map((msg, index) => {
|
||||
console.log(
|
||||
`[QwenSessionManager] Message ${index}: type=${msg.type}, contentLength=${msg.content?.length || 0}`,
|
||||
);
|
||||
return {
|
||||
role: msg.type === 'user' ? 'user' : 'model',
|
||||
parts: [
|
||||
{
|
||||
text: msg.content,
|
||||
},
|
||||
],
|
||||
};
|
||||
});
|
||||
|
||||
console.log(
|
||||
'[QwenSessionManager] Converted',
|
||||
checkpointMessages.length,
|
||||
'messages',
|
||||
);
|
||||
|
||||
const jsonContent = JSON.stringify(checkpointMessages, null, 2);
|
||||
console.log(
|
||||
'[QwenSessionManager] JSON content length:',
|
||||
jsonContent.length,
|
||||
);
|
||||
|
||||
// Save with conversationId as primary tag
|
||||
const convFilename = `checkpoint-${conversationId}.json`;
|
||||
const convFilePath = path.join(projectDir, convFilename);
|
||||
console.log(
|
||||
'[QwenSessionManager] Saving checkpoint with conversationId:',
|
||||
convFilePath,
|
||||
);
|
||||
fs.writeFileSync(convFilePath, jsonContent, 'utf-8');
|
||||
|
||||
// Also save with sessionId if provided (for compatibility with CLI session/load)
|
||||
if (sessionId) {
|
||||
const sessionFilename = `checkpoint-${sessionId}.json`;
|
||||
const sessionFilePath = path.join(projectDir, sessionFilename);
|
||||
console.log(
|
||||
'[QwenSessionManager] Also saving checkpoint with sessionId:',
|
||||
sessionFilePath,
|
||||
);
|
||||
fs.writeFileSync(sessionFilePath, jsonContent, 'utf-8');
|
||||
}
|
||||
|
||||
// Verify primary file exists
|
||||
if (fs.existsSync(convFilePath)) {
|
||||
const stats = fs.statSync(convFilePath);
|
||||
console.log(
|
||||
'[QwenSessionManager] Primary checkpoint verified, size:',
|
||||
stats.size,
|
||||
);
|
||||
} else {
|
||||
console.error(
|
||||
'[QwenSessionManager] ERROR: Primary checkpoint does not exist after write!',
|
||||
);
|
||||
}
|
||||
|
||||
console.log('[QwenSessionManager] ===== CHECKPOINT SAVED =====');
|
||||
console.log('[QwenSessionManager] Primary path:', convFilePath);
|
||||
if (sessionId) {
|
||||
console.log(
|
||||
'[QwenSessionManager] Secondary path (sessionId):',
|
||||
path.join(projectDir, `checkpoint-${sessionId}.json`),
|
||||
);
|
||||
}
|
||||
return conversationId;
|
||||
} catch (error) {
|
||||
console.error('[QwenSessionManager] ===== CHECKPOINT SAVE FAILED =====');
|
||||
console.error('[QwenSessionManager] Error:', error);
|
||||
console.error(
|
||||
'[QwenSessionManager] Error stack:',
|
||||
error instanceof Error ? error.stack : 'N/A',
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save current conversation as a named session (checkpoint-like functionality)
|
||||
*
|
||||
* @param messages - Current conversation messages
|
||||
* @param sessionName - Name/tag for the saved session
|
||||
* @param workingDir - Current working directory
|
||||
* @returns Session ID of the saved session
|
||||
*/
|
||||
async saveSession(
|
||||
messages: QwenMessage[],
|
||||
sessionName: string,
|
||||
workingDir: string,
|
||||
): Promise<string> {
|
||||
try {
|
||||
// Create session directory if it doesn't exist
|
||||
const sessionDir = this.getSessionDir(workingDir);
|
||||
if (!fs.existsSync(sessionDir)) {
|
||||
fs.mkdirSync(sessionDir, { recursive: true });
|
||||
}
|
||||
|
||||
// Generate session ID and filename using CLI's naming convention
|
||||
const sessionId = this.generateSessionId();
|
||||
const shortId = sessionId.split('-')[0]; // First part of UUID (8 chars)
|
||||
const now = new Date();
|
||||
const isoDate = now.toISOString().split('T')[0]; // YYYY-MM-DD
|
||||
const isoTime = now
|
||||
.toISOString()
|
||||
.split('T')[1]
|
||||
.split(':')
|
||||
.slice(0, 2)
|
||||
.join('-'); // HH-MM
|
||||
const filename = `session-${isoDate}T${isoTime}-${shortId}.json`;
|
||||
const filePath = path.join(sessionDir, filename);
|
||||
|
||||
// Create session object
|
||||
const session: QwenSession = {
|
||||
sessionId,
|
||||
projectHash: this.getProjectHash(workingDir),
|
||||
startTime: messages[0]?.timestamp || new Date().toISOString(),
|
||||
lastUpdated: new Date().toISOString(),
|
||||
messages,
|
||||
};
|
||||
|
||||
// Save session to file
|
||||
fs.writeFileSync(filePath, JSON.stringify(session, null, 2), 'utf-8');
|
||||
|
||||
console.log(`[QwenSessionManager] Session saved: ${filePath}`);
|
||||
return sessionId;
|
||||
} catch (error) {
|
||||
console.error('[QwenSessionManager] Failed to save session:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a saved session by name
|
||||
*
|
||||
* @param sessionName - Name/tag of the session to load
|
||||
* @param workingDir - Current working directory
|
||||
* @returns Loaded session or null if not found
|
||||
*/
|
||||
async loadSession(
|
||||
sessionId: string,
|
||||
workingDir: string,
|
||||
): Promise<QwenSession | null> {
|
||||
try {
|
||||
const sessionDir = this.getSessionDir(workingDir);
|
||||
const filename = `session-${sessionId}.json`;
|
||||
const filePath = path.join(sessionDir, filename);
|
||||
|
||||
if (!fs.existsSync(filePath)) {
|
||||
console.log(`[QwenSessionManager] Session file not found: ${filePath}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const content = fs.readFileSync(filePath, 'utf-8');
|
||||
const session = JSON.parse(content) as QwenSession;
|
||||
|
||||
console.log(`[QwenSessionManager] Session loaded: ${filePath}`);
|
||||
return session;
|
||||
} catch (error) {
|
||||
console.error('[QwenSessionManager] Failed to load session:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List all saved sessions
|
||||
*
|
||||
* @param workingDir - Current working directory
|
||||
* @returns Array of session objects
|
||||
*/
|
||||
async listSessions(workingDir: string): Promise<QwenSession[]> {
|
||||
try {
|
||||
const sessionDir = this.getSessionDir(workingDir);
|
||||
|
||||
if (!fs.existsSync(sessionDir)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const files = fs
|
||||
.readdirSync(sessionDir)
|
||||
.filter(
|
||||
(file) => file.startsWith('session-') && file.endsWith('.json'),
|
||||
);
|
||||
|
||||
const sessions: QwenSession[] = [];
|
||||
for (const file of files) {
|
||||
try {
|
||||
const filePath = path.join(sessionDir, file);
|
||||
const content = fs.readFileSync(filePath, 'utf-8');
|
||||
const session = JSON.parse(content) as QwenSession;
|
||||
sessions.push(session);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`[QwenSessionManager] Failed to read session file ${file}:`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by last updated time (newest first)
|
||||
sessions.sort(
|
||||
(a, b) =>
|
||||
new Date(b.lastUpdated).getTime() - new Date(a.lastUpdated).getTime(),
|
||||
);
|
||||
|
||||
return sessions;
|
||||
} catch (error) {
|
||||
console.error('[QwenSessionManager] Failed to list sessions:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a saved session
|
||||
*
|
||||
* @param sessionId - ID of the session to delete
|
||||
* @param workingDir - Current working directory
|
||||
* @returns True if deleted successfully, false otherwise
|
||||
*/
|
||||
async deleteSession(sessionId: string, workingDir: string): Promise<boolean> {
|
||||
try {
|
||||
const sessionDir = this.getSessionDir(workingDir);
|
||||
const filename = `session-${sessionId}.json`;
|
||||
const filePath = path.join(sessionDir, filename);
|
||||
|
||||
if (fs.existsSync(filePath)) {
|
||||
fs.unlinkSync(filePath);
|
||||
console.log(`[QwenSessionManager] Session deleted: ${filePath}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
} catch (error) {
|
||||
console.error('[QwenSessionManager] Failed to delete session:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
177
packages/vscode-ide-companion/src/services/qwenSessionReader.ts
Normal file
177
packages/vscode-ide-companion/src/services/qwenSessionReader.ts
Normal file
@@ -0,0 +1,177 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
|
||||
export interface QwenMessage {
|
||||
id: string;
|
||||
timestamp: string;
|
||||
type: 'user' | 'qwen';
|
||||
content: string;
|
||||
thoughts?: unknown[];
|
||||
tokens?: {
|
||||
input: number;
|
||||
output: number;
|
||||
cached: number;
|
||||
thoughts: number;
|
||||
tool: number;
|
||||
total: number;
|
||||
};
|
||||
model?: string;
|
||||
}
|
||||
|
||||
export interface QwenSession {
|
||||
sessionId: string;
|
||||
projectHash: string;
|
||||
startTime: string;
|
||||
lastUpdated: string;
|
||||
messages: QwenMessage[];
|
||||
filePath?: string;
|
||||
}
|
||||
|
||||
export class QwenSessionReader {
|
||||
private qwenDir: string;
|
||||
|
||||
constructor() {
|
||||
this.qwenDir = path.join(os.homedir(), '.qwen');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all session list (optional: current project only or all projects)
|
||||
*/
|
||||
async getAllSessions(
|
||||
workingDir?: string,
|
||||
allProjects: boolean = false,
|
||||
): Promise<QwenSession[]> {
|
||||
try {
|
||||
const sessions: QwenSession[] = [];
|
||||
|
||||
if (!allProjects && workingDir) {
|
||||
// Current project only
|
||||
const projectHash = await this.getProjectHash(workingDir);
|
||||
const chatsDir = path.join(this.qwenDir, 'tmp', projectHash, 'chats');
|
||||
const projectSessions = await this.readSessionsFromDir(chatsDir);
|
||||
sessions.push(...projectSessions);
|
||||
} else {
|
||||
// All projects
|
||||
const tmpDir = path.join(this.qwenDir, 'tmp');
|
||||
if (!fs.existsSync(tmpDir)) {
|
||||
console.log('[QwenSessionReader] Tmp directory not found:', tmpDir);
|
||||
return [];
|
||||
}
|
||||
|
||||
const projectDirs = fs.readdirSync(tmpDir);
|
||||
for (const projectHash of projectDirs) {
|
||||
const chatsDir = path.join(tmpDir, projectHash, 'chats');
|
||||
const projectSessions = await this.readSessionsFromDir(chatsDir);
|
||||
sessions.push(...projectSessions);
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by last updated time
|
||||
sessions.sort(
|
||||
(a, b) =>
|
||||
new Date(b.lastUpdated).getTime() - new Date(a.lastUpdated).getTime(),
|
||||
);
|
||||
|
||||
return sessions;
|
||||
} catch (error) {
|
||||
console.error('[QwenSessionReader] Failed to get sessions:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read all sessions from specified directory
|
||||
*/
|
||||
private async readSessionsFromDir(chatsDir: string): Promise<QwenSession[]> {
|
||||
const sessions: QwenSession[] = [];
|
||||
|
||||
if (!fs.existsSync(chatsDir)) {
|
||||
return sessions;
|
||||
}
|
||||
|
||||
const files = fs
|
||||
.readdirSync(chatsDir)
|
||||
.filter((f) => f.startsWith('session-') && f.endsWith('.json'));
|
||||
|
||||
for (const file of files) {
|
||||
const filePath = path.join(chatsDir, file);
|
||||
try {
|
||||
const content = fs.readFileSync(filePath, 'utf-8');
|
||||
const session = JSON.parse(content) as QwenSession;
|
||||
session.filePath = filePath;
|
||||
sessions.push(session);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'[QwenSessionReader] Failed to read session file:',
|
||||
filePath,
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return sessions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get details of specific session
|
||||
*/
|
||||
async getSession(
|
||||
sessionId: string,
|
||||
_workingDir?: string,
|
||||
): Promise<QwenSession | null> {
|
||||
// First try to find in all projects
|
||||
const sessions = await this.getAllSessions(undefined, true);
|
||||
return sessions.find((s) => s.sessionId === sessionId) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate project hash (needs to be consistent with Qwen CLI)
|
||||
* Qwen CLI uses SHA256 hash of project path
|
||||
*/
|
||||
private async getProjectHash(workingDir: string): Promise<string> {
|
||||
const crypto = await import('crypto');
|
||||
return crypto.createHash('sha256').update(workingDir).digest('hex');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get session title (based on first user message)
|
||||
*/
|
||||
getSessionTitle(session: QwenSession): string {
|
||||
const firstUserMessage = session.messages.find((m) => m.type === 'user');
|
||||
if (firstUserMessage) {
|
||||
// Extract first 50 characters as title
|
||||
return (
|
||||
firstUserMessage.content.substring(0, 50) +
|
||||
(firstUserMessage.content.length > 50 ? '...' : '')
|
||||
);
|
||||
}
|
||||
return 'Untitled Session';
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete session file
|
||||
*/
|
||||
async deleteSession(
|
||||
sessionId: string,
|
||||
_workingDir: string,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const session = await this.getSession(sessionId, _workingDir);
|
||||
if (session && session.filePath) {
|
||||
fs.unlinkSync(session.filePath);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
} catch (error) {
|
||||
console.error('[QwenSessionReader] Failed to delete session:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* Qwen Session Update Handler
|
||||
*
|
||||
* Handles session updates from ACP and dispatches them to appropriate callbacks
|
||||
*/
|
||||
|
||||
import type { AcpSessionUpdate, ApprovalModeValue } from '../types/acpTypes.js';
|
||||
import type { QwenAgentCallbacks } from '../types/chatTypes.js';
|
||||
|
||||
/**
|
||||
* Qwen Session Update Handler class
|
||||
* Processes various session update events and calls appropriate callbacks
|
||||
*/
|
||||
export class QwenSessionUpdateHandler {
|
||||
private callbacks: QwenAgentCallbacks;
|
||||
|
||||
constructor(callbacks: QwenAgentCallbacks) {
|
||||
this.callbacks = callbacks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update callbacks
|
||||
*
|
||||
* @param callbacks - New callback collection
|
||||
*/
|
||||
updateCallbacks(callbacks: QwenAgentCallbacks): void {
|
||||
this.callbacks = callbacks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle session update
|
||||
*
|
||||
* @param data - ACP session update data
|
||||
*/
|
||||
handleSessionUpdate(data: AcpSessionUpdate): void {
|
||||
const update = data.update;
|
||||
console.log(
|
||||
'[SessionUpdateHandler] Processing update type:',
|
||||
update.sessionUpdate,
|
||||
);
|
||||
|
||||
switch (update.sessionUpdate) {
|
||||
case 'user_message_chunk':
|
||||
if (update.content?.text && this.callbacks.onStreamChunk) {
|
||||
this.callbacks.onStreamChunk(update.content.text);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'agent_message_chunk':
|
||||
if (update.content?.text && this.callbacks.onStreamChunk) {
|
||||
this.callbacks.onStreamChunk(update.content.text);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'agent_thought_chunk':
|
||||
if (update.content?.text) {
|
||||
if (this.callbacks.onThoughtChunk) {
|
||||
this.callbacks.onThoughtChunk(update.content.text);
|
||||
} else if (this.callbacks.onStreamChunk) {
|
||||
// Fallback to regular stream processing
|
||||
console.log(
|
||||
'[SessionUpdateHandler] 🧠 Falling back to onStreamChunk',
|
||||
);
|
||||
this.callbacks.onStreamChunk(update.content.text);
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case 'tool_call': {
|
||||
// Handle new tool call
|
||||
if (this.callbacks.onToolCall && 'toolCallId' in update) {
|
||||
this.callbacks.onToolCall({
|
||||
toolCallId: update.toolCallId as string,
|
||||
kind: (update.kind as string) || undefined,
|
||||
title: (update.title as string) || undefined,
|
||||
status: (update.status as string) || undefined,
|
||||
rawInput: update.rawInput,
|
||||
content: update.content as
|
||||
| Array<Record<string, unknown>>
|
||||
| undefined,
|
||||
locations: update.locations as
|
||||
| Array<{ path: string; line?: number | null }>
|
||||
| undefined,
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'tool_call_update': {
|
||||
if (this.callbacks.onToolCall && 'toolCallId' in update) {
|
||||
this.callbacks.onToolCall({
|
||||
toolCallId: update.toolCallId as string,
|
||||
kind: (update.kind as string) || undefined,
|
||||
title: (update.title as string) || undefined,
|
||||
status: (update.status as string) || undefined,
|
||||
rawInput: update.rawInput,
|
||||
content: update.content as
|
||||
| Array<Record<string, unknown>>
|
||||
| undefined,
|
||||
locations: update.locations as
|
||||
| Array<{ path: string; line?: number | null }>
|
||||
| undefined,
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'plan': {
|
||||
if ('entries' in update) {
|
||||
const entries = update.entries as Array<{
|
||||
content: string;
|
||||
priority: 'high' | 'medium' | 'low';
|
||||
status: 'pending' | 'in_progress' | 'completed';
|
||||
}>;
|
||||
|
||||
if (this.callbacks.onPlan) {
|
||||
this.callbacks.onPlan(entries);
|
||||
} else if (this.callbacks.onStreamChunk) {
|
||||
// Fallback to stream processing
|
||||
const planText =
|
||||
'\n📋 Plan:\n' +
|
||||
entries
|
||||
.map(
|
||||
(entry, i) =>
|
||||
`${i + 1}. [${entry.priority}] ${entry.content}`,
|
||||
)
|
||||
.join('\n');
|
||||
this.callbacks.onStreamChunk(planText);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'current_mode_update': {
|
||||
// Notify UI about mode change
|
||||
try {
|
||||
const modeId = (update as unknown as { modeId?: ApprovalModeValue })
|
||||
.modeId;
|
||||
if (modeId && this.callbacks.onModeChanged) {
|
||||
this.callbacks.onModeChanged(modeId);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
'[SessionUpdateHandler] Failed to handle mode update',
|
||||
err,
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
console.log('[QwenAgentManager] Unhandled session update type');
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
203
packages/vscode-ide-companion/src/types/acpTypes.ts
Normal file
203
packages/vscode-ide-companion/src/types/acpTypes.ts
Normal file
@@ -0,0 +1,203 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
export const JSONRPC_VERSION = '2.0' as const;
|
||||
export const authMethod = 'qwen-oauth';
|
||||
|
||||
export interface AcpRequest {
|
||||
jsonrpc: typeof JSONRPC_VERSION;
|
||||
id: number;
|
||||
method: string;
|
||||
params?: unknown;
|
||||
}
|
||||
|
||||
export interface AcpResponse {
|
||||
jsonrpc: typeof JSONRPC_VERSION;
|
||||
id: number;
|
||||
result?: unknown;
|
||||
capabilities?: {
|
||||
[key: string]: unknown;
|
||||
};
|
||||
error?: {
|
||||
code: number;
|
||||
message: string;
|
||||
data?: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
export interface AcpNotification {
|
||||
jsonrpc: typeof JSONRPC_VERSION;
|
||||
method: string;
|
||||
params?: unknown;
|
||||
}
|
||||
|
||||
export interface BaseSessionUpdate {
|
||||
sessionId: string;
|
||||
}
|
||||
|
||||
// Content block type (simplified version, use schema.ContentBlock for validation)
|
||||
export interface ContentBlock {
|
||||
type: 'text' | 'image';
|
||||
text?: string;
|
||||
data?: string;
|
||||
mimeType?: string;
|
||||
uri?: string;
|
||||
}
|
||||
|
||||
export interface UserMessageChunkUpdate extends BaseSessionUpdate {
|
||||
update: {
|
||||
sessionUpdate: 'user_message_chunk';
|
||||
content: ContentBlock;
|
||||
};
|
||||
}
|
||||
|
||||
export interface AgentMessageChunkUpdate extends BaseSessionUpdate {
|
||||
update: {
|
||||
sessionUpdate: 'agent_message_chunk';
|
||||
content: ContentBlock;
|
||||
};
|
||||
}
|
||||
|
||||
export interface AgentThoughtChunkUpdate extends BaseSessionUpdate {
|
||||
update: {
|
||||
sessionUpdate: 'agent_thought_chunk';
|
||||
content: ContentBlock;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ToolCallUpdate extends BaseSessionUpdate {
|
||||
update: {
|
||||
sessionUpdate: 'tool_call';
|
||||
toolCallId: string;
|
||||
status: 'pending' | 'in_progress' | 'completed' | 'failed';
|
||||
title: string;
|
||||
kind:
|
||||
| 'read'
|
||||
| 'edit'
|
||||
| 'execute'
|
||||
| 'delete'
|
||||
| 'move'
|
||||
| 'search'
|
||||
| 'fetch'
|
||||
| 'think'
|
||||
| 'other';
|
||||
rawInput?: unknown;
|
||||
content?: Array<{
|
||||
type: 'content' | 'diff';
|
||||
content?: {
|
||||
type: 'text';
|
||||
text: string;
|
||||
};
|
||||
path?: string;
|
||||
oldText?: string | null;
|
||||
newText?: string;
|
||||
}>;
|
||||
locations?: Array<{
|
||||
path: string;
|
||||
line?: number | null;
|
||||
}>;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ToolCallStatusUpdate extends BaseSessionUpdate {
|
||||
update: {
|
||||
sessionUpdate: 'tool_call_update';
|
||||
toolCallId: string;
|
||||
status?: 'pending' | 'in_progress' | 'completed' | 'failed';
|
||||
title?: string;
|
||||
kind?: string;
|
||||
rawInput?: unknown;
|
||||
content?: Array<{
|
||||
type: 'content' | 'diff';
|
||||
content?: {
|
||||
type: 'text';
|
||||
text: string;
|
||||
};
|
||||
path?: string;
|
||||
oldText?: string | null;
|
||||
newText?: string;
|
||||
}>;
|
||||
locations?: Array<{
|
||||
path: string;
|
||||
line?: number | null;
|
||||
}>;
|
||||
};
|
||||
}
|
||||
|
||||
export interface PlanUpdate extends BaseSessionUpdate {
|
||||
update: {
|
||||
sessionUpdate: 'plan';
|
||||
entries: Array<{
|
||||
content: string;
|
||||
priority: 'high' | 'medium' | 'low';
|
||||
status: 'pending' | 'in_progress' | 'completed';
|
||||
}>;
|
||||
};
|
||||
}
|
||||
|
||||
export type ApprovalModeValue = 'plan' | 'default' | 'auto-edit' | 'yolo';
|
||||
|
||||
export {
|
||||
ApprovalMode,
|
||||
APPROVAL_MODE_MAP,
|
||||
APPROVAL_MODE_INFO,
|
||||
getApprovalModeInfoFromString,
|
||||
} from './approvalModeTypes.js';
|
||||
|
||||
// Cyclic next-mode mapping used by UI toggles and other consumers
|
||||
export const NEXT_APPROVAL_MODE: {
|
||||
[k in ApprovalModeValue]: ApprovalModeValue;
|
||||
} = {
|
||||
// Hide "plan" from the public toggle sequence for now
|
||||
// Cycle: default -> auto-edit -> yolo -> default
|
||||
default: 'auto-edit',
|
||||
'auto-edit': 'yolo',
|
||||
plan: 'yolo',
|
||||
yolo: 'default',
|
||||
};
|
||||
|
||||
// Current mode update (sent by agent when mode changes)
|
||||
export interface CurrentModeUpdate extends BaseSessionUpdate {
|
||||
update: {
|
||||
sessionUpdate: 'current_mode_update';
|
||||
modeId: ApprovalModeValue;
|
||||
};
|
||||
}
|
||||
|
||||
export type AcpSessionUpdate =
|
||||
| UserMessageChunkUpdate
|
||||
| AgentMessageChunkUpdate
|
||||
| AgentThoughtChunkUpdate
|
||||
| ToolCallUpdate
|
||||
| ToolCallStatusUpdate
|
||||
| PlanUpdate
|
||||
| CurrentModeUpdate;
|
||||
|
||||
// Permission request (simplified version, use schema.RequestPermissionRequest for validation)
|
||||
export interface AcpPermissionRequest {
|
||||
sessionId: string;
|
||||
options: Array<{
|
||||
optionId: string;
|
||||
name: string;
|
||||
kind: 'allow_once' | 'allow_always' | 'reject_once' | 'reject_always';
|
||||
}>;
|
||||
toolCall: {
|
||||
toolCallId: string;
|
||||
rawInput?: {
|
||||
command?: string;
|
||||
description?: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
title?: string;
|
||||
kind?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export type AcpMessage =
|
||||
| AcpRequest
|
||||
| AcpNotification
|
||||
| AcpResponse
|
||||
| AcpSessionUpdate;
|
||||
79
packages/vscode-ide-companion/src/types/approvalModeTypes.ts
Normal file
79
packages/vscode-ide-companion/src/types/approvalModeTypes.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* Enum for approval modes with UI-friendly labels
|
||||
* Represents the different approval modes available in the ACP protocol
|
||||
* with their corresponding user-facing display names
|
||||
*/
|
||||
export enum ApprovalMode {
|
||||
PLAN = 'plan',
|
||||
DEFAULT = 'default',
|
||||
AUTO_EDIT = 'auto-edit',
|
||||
YOLO = 'yolo',
|
||||
}
|
||||
|
||||
/**
|
||||
* Mapping from string values to enum values for runtime conversion
|
||||
*/
|
||||
export const APPROVAL_MODE_MAP: Record<string, ApprovalMode> = {
|
||||
plan: ApprovalMode.PLAN,
|
||||
default: ApprovalMode.DEFAULT,
|
||||
'auto-edit': ApprovalMode.AUTO_EDIT,
|
||||
yolo: ApprovalMode.YOLO,
|
||||
};
|
||||
|
||||
/**
|
||||
* UI display information for each approval mode
|
||||
*/
|
||||
export const APPROVAL_MODE_INFO: Record<
|
||||
ApprovalMode,
|
||||
{
|
||||
label: string;
|
||||
title: string;
|
||||
iconType?: 'edit' | 'auto' | 'plan' | 'yolo';
|
||||
}
|
||||
> = {
|
||||
[ApprovalMode.PLAN]: {
|
||||
label: 'Plan mode',
|
||||
title: 'Qwen will plan before executing. Click to switch modes.',
|
||||
iconType: 'plan',
|
||||
},
|
||||
[ApprovalMode.DEFAULT]: {
|
||||
label: 'Ask before edits',
|
||||
title: 'Qwen will ask before each edit. Click to switch modes.',
|
||||
iconType: 'edit',
|
||||
},
|
||||
[ApprovalMode.AUTO_EDIT]: {
|
||||
label: 'Edit automatically',
|
||||
title: 'Qwen will edit files automatically. Click to switch modes.',
|
||||
iconType: 'auto',
|
||||
},
|
||||
[ApprovalMode.YOLO]: {
|
||||
label: 'YOLO',
|
||||
title: 'Automatically approve all tools. Click to switch modes.',
|
||||
iconType: 'yolo',
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Get UI display information for an approval mode from string value
|
||||
*/
|
||||
export function getApprovalModeInfoFromString(mode: string): {
|
||||
label: string;
|
||||
title: string;
|
||||
iconType?: 'edit' | 'auto' | 'plan' | 'yolo';
|
||||
} {
|
||||
const enumValue = APPROVAL_MODE_MAP[mode];
|
||||
if (enumValue !== undefined) {
|
||||
return APPROVAL_MODE_INFO[enumValue];
|
||||
}
|
||||
return {
|
||||
label: 'Unknown mode',
|
||||
title: 'Unknown edit mode',
|
||||
iconType: undefined,
|
||||
};
|
||||
}
|
||||
73
packages/vscode-ide-companion/src/types/chatTypes.ts
Normal file
73
packages/vscode-ide-companion/src/types/chatTypes.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
import type { AcpPermissionRequest, ApprovalModeValue } from './acpTypes.js';
|
||||
|
||||
export interface ChatMessage {
|
||||
role: 'user' | 'assistant';
|
||||
content: string;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
export interface PlanEntry {
|
||||
content: string;
|
||||
priority?: 'high' | 'medium' | 'low';
|
||||
status: 'pending' | 'in_progress' | 'completed';
|
||||
}
|
||||
|
||||
export interface ToolCallUpdateData {
|
||||
toolCallId: string;
|
||||
kind?: string;
|
||||
title?: string;
|
||||
status?: string;
|
||||
rawInput?: unknown;
|
||||
content?: Array<Record<string, unknown>>;
|
||||
locations?: Array<{ path: string; line?: number | null }>;
|
||||
}
|
||||
|
||||
export interface QwenAgentCallbacks {
|
||||
onMessage?: (message: ChatMessage) => void;
|
||||
onStreamChunk?: (chunk: string) => void;
|
||||
onThoughtChunk?: (chunk: string) => void;
|
||||
onToolCall?: (update: ToolCallUpdateData) => void;
|
||||
onPlan?: (entries: PlanEntry[]) => void;
|
||||
onPermissionRequest?: (request: AcpPermissionRequest) => Promise<string>;
|
||||
onEndTurn?: () => void;
|
||||
onModeInfo?: (info: {
|
||||
currentModeId?: ApprovalModeValue;
|
||||
availableModes?: Array<{
|
||||
id: ApprovalModeValue;
|
||||
name: string;
|
||||
description: string;
|
||||
}>;
|
||||
}) => void;
|
||||
onModeChanged?: (modeId: ApprovalModeValue) => void;
|
||||
}
|
||||
|
||||
export interface ToolCallUpdate {
|
||||
type: 'tool_call' | 'tool_call_update';
|
||||
toolCallId: string;
|
||||
kind?: string;
|
||||
title?: string;
|
||||
status?: 'pending' | 'in_progress' | 'completed' | 'failed';
|
||||
rawInput?: unknown;
|
||||
content?: Array<{
|
||||
type: 'content' | 'diff';
|
||||
content?: {
|
||||
type: string;
|
||||
text?: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
path?: string;
|
||||
oldText?: string | null;
|
||||
newText?: string;
|
||||
[key: string]: unknown;
|
||||
}>;
|
||||
locations?: Array<{
|
||||
path: string;
|
||||
line?: number | null;
|
||||
}>;
|
||||
timestamp?: number; // Add timestamp field for message ordering
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
|
||||
export interface CompletionItem {
|
||||
id: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
icon?: React.ReactNode;
|
||||
type: 'file' | 'folder' | 'symbol' | 'command' | 'variable' | 'info';
|
||||
// Value inserted into the input when selected (e.g., filename or command)
|
||||
value?: string;
|
||||
// Optional full path for files (used to build @filename -> full path mapping)
|
||||
path?: string;
|
||||
}
|
||||
31
packages/vscode-ide-companion/src/types/connectionTypes.ts
Normal file
31
packages/vscode-ide-companion/src/types/connectionTypes.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { ChildProcess } from 'child_process';
|
||||
import type { AcpSessionUpdate, AcpPermissionRequest } from './acpTypes.js';
|
||||
|
||||
export interface PendingRequest<T = unknown> {
|
||||
resolve: (value: T) => void;
|
||||
reject: (error: Error) => void;
|
||||
timeoutId?: NodeJS.Timeout;
|
||||
method: string;
|
||||
}
|
||||
|
||||
export interface AcpConnectionCallbacks {
|
||||
onSessionUpdate: (data: AcpSessionUpdate) => void;
|
||||
onPermissionRequest: (data: AcpPermissionRequest) => Promise<{
|
||||
optionId: string;
|
||||
}>;
|
||||
onEndTurn: () => void;
|
||||
}
|
||||
|
||||
export interface AcpConnectionState {
|
||||
child: ChildProcess | null;
|
||||
pendingRequests: Map<number, PendingRequest<unknown>>;
|
||||
nextRequestId: number;
|
||||
sessionId: string | null;
|
||||
isInitialized: boolean;
|
||||
}
|
||||
116
packages/vscode-ide-companion/src/utils/editorGroupUtils.ts
Normal file
116
packages/vscode-ide-companion/src/utils/editorGroupUtils.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import * as vscode from 'vscode';
|
||||
import { openChatCommand } from '../commands/index.js';
|
||||
|
||||
/**
|
||||
* Find the editor group immediately to the left of the Qwen chat webview.
|
||||
* - If the chat webview group is the leftmost group, returns undefined.
|
||||
* - Uses the webview tab viewType 'mainThreadWebview-qwenCode.chat'.
|
||||
*/
|
||||
export function findLeftGroupOfChatWebview(): vscode.ViewColumn | undefined {
|
||||
try {
|
||||
const groups = vscode.window.tabGroups.all;
|
||||
|
||||
// Locate the group that contains our chat webview
|
||||
const webviewGroup = groups.find((group) =>
|
||||
group.tabs.some((tab) => {
|
||||
const input: unknown = (tab as { input?: unknown }).input;
|
||||
const isWebviewInput = (inp: unknown): inp is { viewType: string } =>
|
||||
!!inp && typeof inp === 'object' && 'viewType' in inp;
|
||||
return (
|
||||
isWebviewInput(input) &&
|
||||
input.viewType === 'mainThreadWebview-qwenCode.chat'
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
if (!webviewGroup) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Among all groups to the left (smaller viewColumn), choose the one with
|
||||
// the largest viewColumn value (i.e. the immediate neighbor on the left).
|
||||
let candidate:
|
||||
| { group: vscode.TabGroup; viewColumn: vscode.ViewColumn }
|
||||
| undefined;
|
||||
for (const g of groups) {
|
||||
if (g.viewColumn < webviewGroup.viewColumn) {
|
||||
if (!candidate || g.viewColumn > candidate.viewColumn) {
|
||||
candidate = { group: g, viewColumn: g.viewColumn };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return candidate?.viewColumn;
|
||||
} catch (_err) {
|
||||
// Best-effort only; fall back to default behavior if anything goes wrong
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure there is an editor group directly to the left of the Qwen chat webview.
|
||||
* - If one exists, return its ViewColumn.
|
||||
* - If none exists, focus the chat panel and create a new group on its left,
|
||||
* then return the new group's ViewColumn (which equals the chat's previous column).
|
||||
* - If the chat webview cannot be located, returns undefined.
|
||||
*/
|
||||
export async function ensureLeftGroupOfChatWebview(): Promise<
|
||||
vscode.ViewColumn | undefined
|
||||
> {
|
||||
// First try to find an existing left neighbor
|
||||
const existing = findLeftGroupOfChatWebview();
|
||||
if (existing !== undefined) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
// Locate the chat webview group
|
||||
const groups = vscode.window.tabGroups.all;
|
||||
const webviewGroup = groups.find((group) =>
|
||||
group.tabs.some((tab) => {
|
||||
const input: unknown = (tab as { input?: unknown }).input;
|
||||
const isWebviewInput = (inp: unknown): inp is { viewType: string } =>
|
||||
!!inp && typeof inp === 'object' && 'viewType' in inp;
|
||||
return (
|
||||
isWebviewInput(input) &&
|
||||
input.viewType === 'mainThreadWebview-qwenCode.chat'
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
if (!webviewGroup) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const previousChatColumn = webviewGroup.viewColumn;
|
||||
|
||||
// Make the chat group active by revealing the panel
|
||||
try {
|
||||
await vscode.commands.executeCommand(openChatCommand);
|
||||
} catch {
|
||||
// Best-effort; continue even if this fails
|
||||
}
|
||||
|
||||
// Create a new group to the left of the chat group
|
||||
try {
|
||||
await vscode.commands.executeCommand('workbench.action.newGroupLeft');
|
||||
} catch {
|
||||
// If we fail to create a group, fall back to default behavior
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Restore focus to chat (optional), so we don't disturb user focus
|
||||
try {
|
||||
await vscode.commands.executeCommand(openChatCommand);
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
|
||||
// The new left group's column equals the chat's previous column
|
||||
return previousChatColumn;
|
||||
}
|
||||
749
packages/vscode-ide-companion/src/webview/App.tsx
Normal file
749
packages/vscode-ide-companion/src/webview/App.tsx
Normal file
@@ -0,0 +1,749 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import React, {
|
||||
useState,
|
||||
useEffect,
|
||||
useRef,
|
||||
useCallback,
|
||||
useMemo,
|
||||
useLayoutEffect,
|
||||
} from 'react';
|
||||
import { useVSCode } from './hooks/useVSCode.js';
|
||||
import { useSessionManagement } from './hooks/session/useSessionManagement.js';
|
||||
import { useFileContext } from './hooks/file/useFileContext.js';
|
||||
import { useMessageHandling } from './hooks/message/useMessageHandling.js';
|
||||
import { useToolCalls } from './hooks/useToolCalls.js';
|
||||
import { useWebViewMessages } from './hooks/useWebViewMessages.js';
|
||||
import { useMessageSubmit } from './hooks/useMessageSubmit.js';
|
||||
import type {
|
||||
PermissionOption,
|
||||
ToolCall as PermissionToolCall,
|
||||
} from './components/PermissionDrawer/PermissionRequest.js';
|
||||
import type { TextMessage } from './hooks/message/useMessageHandling.js';
|
||||
import type { ToolCallData } from './components/messages/toolcalls/ToolCall.js';
|
||||
import { PermissionDrawer } from './components/PermissionDrawer/PermissionDrawer.js';
|
||||
import { ToolCall } from './components/messages/toolcalls/ToolCall.js';
|
||||
import { hasToolCallOutput } from './components/messages/toolcalls/shared/utils.js';
|
||||
import { EmptyState } from './components/layout/EmptyState.js';
|
||||
import { type CompletionItem } from '../types/completionItemTypes.js';
|
||||
import { useCompletionTrigger } from './hooks/useCompletionTrigger.js';
|
||||
import { ChatHeader } from './components/layout/ChatHeader.js';
|
||||
import {
|
||||
UserMessage,
|
||||
AssistantMessage,
|
||||
ThinkingMessage,
|
||||
WaitingMessage,
|
||||
InterruptedMessage,
|
||||
} from './components/messages/index.js';
|
||||
import { InputForm } from './components/layout/InputForm.js';
|
||||
import { SessionSelector } from './components/layout/SessionSelector.js';
|
||||
import { FileIcon, UserIcon } from './components/icons/index.js';
|
||||
import { ApprovalMode, NEXT_APPROVAL_MODE } from '../types/acpTypes.js';
|
||||
import type { ApprovalModeValue } from '../types/acpTypes.js';
|
||||
import type { PlanEntry } from '../types/chatTypes.js';
|
||||
|
||||
export const App: React.FC = () => {
|
||||
const vscode = useVSCode();
|
||||
|
||||
// Core hooks
|
||||
const sessionManagement = useSessionManagement(vscode);
|
||||
const fileContext = useFileContext(vscode);
|
||||
const messageHandling = useMessageHandling();
|
||||
const {
|
||||
inProgressToolCalls,
|
||||
completedToolCalls,
|
||||
handleToolCallUpdate,
|
||||
clearToolCalls,
|
||||
} = useToolCalls();
|
||||
|
||||
// UI state
|
||||
const [inputText, setInputText] = useState('');
|
||||
const [permissionRequest, setPermissionRequest] = useState<{
|
||||
options: PermissionOption[];
|
||||
toolCall: PermissionToolCall;
|
||||
} | null>(null);
|
||||
const [planEntries, setPlanEntries] = useState<PlanEntry[]>([]);
|
||||
const messagesEndRef = useRef<HTMLDivElement>(
|
||||
null,
|
||||
) as React.RefObject<HTMLDivElement>;
|
||||
// Scroll container for message list; used to keep the view anchored to the latest content
|
||||
const messagesContainerRef = useRef<HTMLDivElement>(
|
||||
null,
|
||||
) as React.RefObject<HTMLDivElement>;
|
||||
const inputFieldRef = useRef<HTMLDivElement>(
|
||||
null,
|
||||
) as React.RefObject<HTMLDivElement>;
|
||||
|
||||
const [editMode, setEditMode] = useState<ApprovalModeValue>(
|
||||
ApprovalMode.DEFAULT,
|
||||
);
|
||||
const [thinkingEnabled, setThinkingEnabled] = useState(false);
|
||||
const [isComposing, setIsComposing] = useState(false);
|
||||
// When true, do NOT auto-attach the active editor file/selection to message context
|
||||
const [skipAutoActiveContext, setSkipAutoActiveContext] = useState(false);
|
||||
|
||||
// Completion system
|
||||
const getCompletionItems = React.useCallback(
|
||||
async (trigger: '@' | '/', query: string): Promise<CompletionItem[]> => {
|
||||
if (trigger === '@') {
|
||||
if (!fileContext.hasRequestedFiles) {
|
||||
fileContext.requestWorkspaceFiles();
|
||||
}
|
||||
|
||||
const fileIcon = <FileIcon />;
|
||||
const allItems: CompletionItem[] = fileContext.workspaceFiles.map(
|
||||
(file) => ({
|
||||
id: file.id,
|
||||
label: file.label,
|
||||
description: file.description,
|
||||
type: 'file' as const,
|
||||
icon: fileIcon,
|
||||
// Insert filename after @, keep path for mapping
|
||||
value: file.label,
|
||||
path: file.path,
|
||||
}),
|
||||
);
|
||||
|
||||
if (query && query.length >= 1) {
|
||||
fileContext.requestWorkspaceFiles(query);
|
||||
const lowerQuery = query.toLowerCase();
|
||||
return allItems.filter(
|
||||
(item) =>
|
||||
item.label.toLowerCase().includes(lowerQuery) ||
|
||||
(item.description &&
|
||||
item.description.toLowerCase().includes(lowerQuery)),
|
||||
);
|
||||
}
|
||||
|
||||
// If first time and still loading, show a placeholder
|
||||
if (allItems.length === 0) {
|
||||
return [
|
||||
{
|
||||
id: 'loading-files',
|
||||
label: 'Searching files…',
|
||||
description: 'Type to filter, or wait a moment…',
|
||||
type: 'info' as const,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
return allItems;
|
||||
} else {
|
||||
// Handle slash commands
|
||||
const commands: CompletionItem[] = [
|
||||
{
|
||||
id: 'login',
|
||||
label: '/login',
|
||||
description: 'Login to Qwen Code',
|
||||
type: 'command',
|
||||
icon: <UserIcon />,
|
||||
},
|
||||
];
|
||||
|
||||
return commands.filter((cmd) =>
|
||||
cmd.label.toLowerCase().includes(query.toLowerCase()),
|
||||
);
|
||||
}
|
||||
},
|
||||
[fileContext],
|
||||
);
|
||||
|
||||
const completion = useCompletionTrigger(inputFieldRef, getCompletionItems);
|
||||
|
||||
// When workspace files update while menu open for @, refresh items so the first @ shows the list
|
||||
// Note: Avoid depending on the entire `completion` object here, since its identity
|
||||
// changes on every render which would retrigger this effect and can cause a refresh loop.
|
||||
useEffect(() => {
|
||||
if (completion.isOpen && completion.triggerChar === '@') {
|
||||
// Only refresh items; do not change other completion state to avoid re-renders loops
|
||||
completion.refreshCompletion();
|
||||
}
|
||||
// Only re-run when the actual data source changes, not on every render
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [fileContext.workspaceFiles, completion.isOpen, completion.triggerChar]);
|
||||
|
||||
// Message submission
|
||||
const handleSubmit = useMessageSubmit({
|
||||
inputText,
|
||||
setInputText,
|
||||
messageHandling,
|
||||
fileContext,
|
||||
skipAutoActiveContext,
|
||||
vscode,
|
||||
inputFieldRef,
|
||||
isStreaming: messageHandling.isStreaming,
|
||||
});
|
||||
|
||||
// Handle cancel/stop from the input bar
|
||||
// Emit a cancel to the extension and immediately reflect interruption locally.
|
||||
const handleCancel = useCallback(() => {
|
||||
if (messageHandling.isStreaming || messageHandling.isWaitingForResponse) {
|
||||
// Proactively end local states and add an 'Interrupted' line
|
||||
try {
|
||||
messageHandling.endStreaming?.();
|
||||
} catch {
|
||||
/* no-op */
|
||||
}
|
||||
try {
|
||||
messageHandling.clearWaitingForResponse?.();
|
||||
} catch {
|
||||
/* no-op */
|
||||
}
|
||||
messageHandling.addMessage({
|
||||
role: 'assistant',
|
||||
content: 'Interrupted',
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
}
|
||||
// Notify extension/agent to cancel server-side work
|
||||
vscode.postMessage({
|
||||
type: 'cancelStreaming',
|
||||
data: {},
|
||||
});
|
||||
}, [messageHandling, vscode]);
|
||||
|
||||
// Message handling
|
||||
useWebViewMessages({
|
||||
sessionManagement,
|
||||
fileContext,
|
||||
messageHandling,
|
||||
handleToolCallUpdate,
|
||||
clearToolCalls,
|
||||
setPlanEntries,
|
||||
handlePermissionRequest: setPermissionRequest,
|
||||
inputFieldRef,
|
||||
setInputText,
|
||||
setEditMode,
|
||||
});
|
||||
|
||||
// Auto-scroll handling: keep the view pinned to bottom when new content arrives,
|
||||
// but don't interrupt the user if they scrolled up.
|
||||
// We track whether the user is currently "pinned" to the bottom (near the end).
|
||||
const [pinnedToBottom, setPinnedToBottom] = useState(true);
|
||||
const prevCountsRef = useRef({ msgLen: 0, inProgLen: 0, doneLen: 0 });
|
||||
|
||||
// Observe scroll position to know if user has scrolled away from the bottom.
|
||||
useEffect(() => {
|
||||
const container = messagesContainerRef.current;
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
|
||||
const onScroll = () => {
|
||||
// Use a small threshold so slight deltas don't flip the state.
|
||||
// Note: there's extra bottom padding for the input area, so keep this a bit generous.
|
||||
const threshold = 80; // px tolerance
|
||||
const distanceFromBottom =
|
||||
container.scrollHeight - (container.scrollTop + container.clientHeight);
|
||||
setPinnedToBottom(distanceFromBottom <= threshold);
|
||||
};
|
||||
|
||||
// Initialize once mounted so first render is correct
|
||||
onScroll();
|
||||
container.addEventListener('scroll', onScroll, { passive: true });
|
||||
return () => container.removeEventListener('scroll', onScroll);
|
||||
}, []);
|
||||
|
||||
// When content changes, if the user is pinned to bottom, keep it anchored there.
|
||||
// Only smooth-scroll when new items are appended; do not smooth for streaming chunk updates.
|
||||
useLayoutEffect(() => {
|
||||
const container = messagesContainerRef.current;
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Detect whether new items were appended (vs. streaming chunk updates)
|
||||
const prev = prevCountsRef.current;
|
||||
const newMsg = messageHandling.messages.length > prev.msgLen;
|
||||
const newInProg = inProgressToolCalls.length > prev.inProgLen;
|
||||
const newDone = completedToolCalls.length > prev.doneLen;
|
||||
prevCountsRef.current = {
|
||||
msgLen: messageHandling.messages.length,
|
||||
inProgLen: inProgressToolCalls.length,
|
||||
doneLen: completedToolCalls.length,
|
||||
};
|
||||
|
||||
if (!pinnedToBottom) {
|
||||
// Do nothing if user scrolled away; avoid stealing scroll.
|
||||
return;
|
||||
}
|
||||
|
||||
const smooth = newMsg || newInProg || newDone; // avoid smooth on streaming chunks
|
||||
|
||||
// Anchor to the bottom on next frame to avoid layout thrash.
|
||||
const raf = requestAnimationFrame(() => {
|
||||
const top = container.scrollHeight - container.clientHeight;
|
||||
// Use scrollTo to avoid cross-context issues with scrollIntoView.
|
||||
container.scrollTo({ top, behavior: smooth ? 'smooth' : 'auto' });
|
||||
});
|
||||
return () => cancelAnimationFrame(raf);
|
||||
}, [
|
||||
pinnedToBottom,
|
||||
messageHandling.messages,
|
||||
inProgressToolCalls,
|
||||
completedToolCalls,
|
||||
messageHandling.isWaitingForResponse,
|
||||
messageHandling.loadingMessage,
|
||||
messageHandling.isStreaming,
|
||||
planEntries,
|
||||
]);
|
||||
|
||||
// When the last rendered item resizes (e.g., images/code blocks load/expand),
|
||||
// if we're pinned to bottom, keep it anchored there.
|
||||
useEffect(() => {
|
||||
const container = messagesContainerRef.current;
|
||||
const endEl = messagesEndRef.current;
|
||||
if (!container || !endEl) {
|
||||
return;
|
||||
}
|
||||
|
||||
const lastItem = endEl.previousElementSibling as HTMLElement | null;
|
||||
if (!lastItem) {
|
||||
return;
|
||||
}
|
||||
|
||||
let frame = 0;
|
||||
const ro = new ResizeObserver(() => {
|
||||
if (!pinnedToBottom) {
|
||||
return;
|
||||
}
|
||||
// Defer to next frame to avoid thrash during rapid size changes
|
||||
cancelAnimationFrame(frame);
|
||||
frame = requestAnimationFrame(() => {
|
||||
const top = container.scrollHeight - container.clientHeight;
|
||||
container.scrollTo({ top });
|
||||
});
|
||||
});
|
||||
ro.observe(lastItem);
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(frame);
|
||||
ro.disconnect();
|
||||
};
|
||||
}, [
|
||||
pinnedToBottom,
|
||||
messageHandling.messages,
|
||||
inProgressToolCalls,
|
||||
completedToolCalls,
|
||||
]);
|
||||
|
||||
// Handle permission response
|
||||
const handlePermissionResponse = useCallback(
|
||||
(optionId: string) => {
|
||||
// Forward the selected optionId directly to extension as ACP permission response
|
||||
// Expected values include: 'proceed_once', 'proceed_always', 'cancel', 'proceed_always_server', etc.
|
||||
vscode.postMessage({
|
||||
type: 'permissionResponse',
|
||||
data: { optionId },
|
||||
});
|
||||
setPermissionRequest(null);
|
||||
},
|
||||
[vscode],
|
||||
);
|
||||
|
||||
// Handle completion selection
|
||||
const handleCompletionSelect = useCallback(
|
||||
(item: CompletionItem) => {
|
||||
// Handle completion selection by inserting the value into the input field
|
||||
const inputElement = inputFieldRef.current;
|
||||
if (!inputElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Ignore info items (placeholders like "Searching files…")
|
||||
if (item.type === 'info') {
|
||||
completion.closeCompletion();
|
||||
return;
|
||||
}
|
||||
|
||||
// Slash commands can execute immediately
|
||||
if (item.type === 'command') {
|
||||
const command = (item.label || '').trim();
|
||||
if (command === '/login') {
|
||||
vscode.postMessage({ type: 'login', data: {} });
|
||||
completion.closeCompletion();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// If selecting a file, add @filename -> fullpath mapping
|
||||
if (item.type === 'file' && item.value && item.path) {
|
||||
try {
|
||||
fileContext.addFileReference(item.value, item.path);
|
||||
} catch (err) {
|
||||
console.warn('[App] addFileReference failed:', err);
|
||||
}
|
||||
}
|
||||
|
||||
const selection = window.getSelection();
|
||||
if (!selection || selection.rangeCount === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Current text and cursor
|
||||
const text = inputElement.textContent || '';
|
||||
const range = selection.getRangeAt(0);
|
||||
|
||||
// Compute total text offset for contentEditable
|
||||
let cursorPos = text.length;
|
||||
if (range.startContainer === inputElement) {
|
||||
const childIndex = range.startOffset;
|
||||
let offset = 0;
|
||||
for (
|
||||
let i = 0;
|
||||
i < childIndex && i < inputElement.childNodes.length;
|
||||
i++
|
||||
) {
|
||||
offset += inputElement.childNodes[i].textContent?.length || 0;
|
||||
}
|
||||
cursorPos = offset || text.length;
|
||||
} else if (range.startContainer.nodeType === Node.TEXT_NODE) {
|
||||
const walker = document.createTreeWalker(
|
||||
inputElement,
|
||||
NodeFilter.SHOW_TEXT,
|
||||
null,
|
||||
);
|
||||
let offset = 0;
|
||||
let found = false;
|
||||
let node: Node | null = walker.nextNode();
|
||||
while (node) {
|
||||
if (node === range.startContainer) {
|
||||
offset += range.startOffset;
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
offset += node.textContent?.length || 0;
|
||||
node = walker.nextNode();
|
||||
}
|
||||
cursorPos = found ? offset : text.length;
|
||||
}
|
||||
|
||||
// Replace from trigger to cursor with selected value
|
||||
const textBeforeCursor = text.substring(0, cursorPos);
|
||||
const atPos = textBeforeCursor.lastIndexOf('@');
|
||||
const slashPos = textBeforeCursor.lastIndexOf('/');
|
||||
const triggerPos = Math.max(atPos, slashPos);
|
||||
|
||||
if (triggerPos >= 0) {
|
||||
const insertValue =
|
||||
typeof item.value === 'string' ? item.value : String(item.label);
|
||||
const newText =
|
||||
text.substring(0, triggerPos + 1) + // keep the trigger symbol
|
||||
insertValue +
|
||||
' ' +
|
||||
text.substring(cursorPos);
|
||||
|
||||
// Update DOM and state, and move caret to end
|
||||
inputElement.textContent = newText;
|
||||
setInputText(newText);
|
||||
|
||||
const newRange = document.createRange();
|
||||
const sel = window.getSelection();
|
||||
newRange.selectNodeContents(inputElement);
|
||||
newRange.collapse(false);
|
||||
sel?.removeAllRanges();
|
||||
sel?.addRange(newRange);
|
||||
}
|
||||
|
||||
// Close the completion menu
|
||||
completion.closeCompletion();
|
||||
},
|
||||
[completion, inputFieldRef, setInputText, fileContext, vscode],
|
||||
);
|
||||
|
||||
// Handle attach context click
|
||||
const handleAttachContextClick = useCallback(() => {
|
||||
// Open native file picker (different from '@' completion which searches workspace files)
|
||||
vscode.postMessage({
|
||||
type: 'attachFile',
|
||||
data: {},
|
||||
});
|
||||
}, [vscode]);
|
||||
|
||||
// Handle toggle edit mode (Default -> Auto-edit -> YOLO -> Default)
|
||||
const handleToggleEditMode = useCallback(() => {
|
||||
setEditMode((prev) => {
|
||||
const next: ApprovalModeValue = NEXT_APPROVAL_MODE[prev];
|
||||
|
||||
// Notify extension to set approval mode via ACP
|
||||
try {
|
||||
vscode.postMessage({
|
||||
type: 'setApprovalMode',
|
||||
data: { modeId: next },
|
||||
});
|
||||
} catch {
|
||||
/* no-op */
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, [vscode]);
|
||||
|
||||
// Handle toggle thinking
|
||||
const handleToggleThinking = () => {
|
||||
setThinkingEnabled((prev) => !prev);
|
||||
};
|
||||
|
||||
// Create unified message array containing all types of messages and tool calls
|
||||
const allMessages = useMemo<
|
||||
Array<{
|
||||
type: 'message' | 'in-progress-tool-call' | 'completed-tool-call';
|
||||
data: TextMessage | ToolCallData;
|
||||
timestamp: number;
|
||||
}>
|
||||
>(() => {
|
||||
// Regular messages
|
||||
const regularMessages = messageHandling.messages.map((msg) => ({
|
||||
type: 'message' as const,
|
||||
data: msg,
|
||||
timestamp: msg.timestamp,
|
||||
}));
|
||||
|
||||
// In-progress tool calls
|
||||
const inProgressTools = inProgressToolCalls.map((toolCall) => ({
|
||||
type: 'in-progress-tool-call' as const,
|
||||
data: toolCall,
|
||||
timestamp: toolCall.timestamp || Date.now(),
|
||||
}));
|
||||
|
||||
// Completed tool calls
|
||||
const completedTools = completedToolCalls
|
||||
.filter(hasToolCallOutput)
|
||||
.map((toolCall) => ({
|
||||
type: 'completed-tool-call' as const,
|
||||
data: toolCall,
|
||||
timestamp: toolCall.timestamp || Date.now(),
|
||||
}));
|
||||
|
||||
// Merge and sort by timestamp to ensure messages and tool calls are interleaved
|
||||
return [...regularMessages, ...inProgressTools, ...completedTools].sort(
|
||||
(a, b) => (a.timestamp || 0) - (b.timestamp || 0),
|
||||
);
|
||||
}, [messageHandling.messages, inProgressToolCalls, completedToolCalls]);
|
||||
|
||||
console.log('[App] Rendering messages:', allMessages);
|
||||
|
||||
// Render all messages and tool calls
|
||||
const renderMessages = useCallback<() => React.ReactNode>(
|
||||
() =>
|
||||
allMessages.map((item, index) => {
|
||||
switch (item.type) {
|
||||
case 'message': {
|
||||
const msg = item.data as TextMessage;
|
||||
const handleFileClick = (path: string): void => {
|
||||
vscode.postMessage({
|
||||
type: 'openFile',
|
||||
data: { path },
|
||||
});
|
||||
};
|
||||
|
||||
if (msg.role === 'thinking') {
|
||||
return (
|
||||
<ThinkingMessage
|
||||
key={`message-${index}`}
|
||||
content={msg.content || ''}
|
||||
timestamp={msg.timestamp || 0}
|
||||
onFileClick={handleFileClick}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (msg.role === 'user') {
|
||||
return (
|
||||
<UserMessage
|
||||
key={`message-${index}`}
|
||||
content={msg.content || ''}
|
||||
timestamp={msg.timestamp || 0}
|
||||
onFileClick={handleFileClick}
|
||||
fileContext={msg.fileContext}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
{
|
||||
const content = (msg.content || '').trim();
|
||||
if (content === 'Interrupted' || content === 'Tool interrupted') {
|
||||
return (
|
||||
<InterruptedMessage key={`message-${index}`} text={content} />
|
||||
);
|
||||
}
|
||||
return (
|
||||
<AssistantMessage
|
||||
key={`message-${index}`}
|
||||
content={content}
|
||||
timestamp={msg.timestamp || 0}
|
||||
onFileClick={handleFileClick}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
case 'in-progress-tool-call':
|
||||
case 'completed-tool-call': {
|
||||
const prev = allMessages[index - 1];
|
||||
const next = allMessages[index + 1];
|
||||
const isToolCallType = (
|
||||
x: unknown,
|
||||
): x is { type: 'in-progress-tool-call' | 'completed-tool-call' } =>
|
||||
!!x &&
|
||||
typeof x === 'object' &&
|
||||
'type' in (x as Record<string, unknown>) &&
|
||||
((x as { type: string }).type === 'in-progress-tool-call' ||
|
||||
(x as { type: string }).type === 'completed-tool-call');
|
||||
const isFirst = !isToolCallType(prev);
|
||||
const isLast = !isToolCallType(next);
|
||||
return (
|
||||
<ToolCall
|
||||
key={`toolcall-${(item.data as ToolCallData).toolCallId}-${item.type}`}
|
||||
toolCall={item.data as ToolCallData}
|
||||
isFirst={isFirst}
|
||||
isLast={isLast}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}),
|
||||
[allMessages, vscode],
|
||||
);
|
||||
|
||||
const hasContent =
|
||||
messageHandling.messages.length > 0 ||
|
||||
messageHandling.isStreaming ||
|
||||
inProgressToolCalls.length > 0 ||
|
||||
completedToolCalls.length > 0 ||
|
||||
planEntries.length > 0 ||
|
||||
allMessages.length > 0;
|
||||
|
||||
return (
|
||||
<div className="chat-container">
|
||||
<SessionSelector
|
||||
visible={sessionManagement.showSessionSelector}
|
||||
sessions={sessionManagement.filteredSessions}
|
||||
currentSessionId={sessionManagement.currentSessionId}
|
||||
searchQuery={sessionManagement.sessionSearchQuery}
|
||||
onSearchChange={sessionManagement.setSessionSearchQuery}
|
||||
onSelectSession={(sessionId) => {
|
||||
sessionManagement.handleSwitchSession(sessionId);
|
||||
sessionManagement.setSessionSearchQuery('');
|
||||
}}
|
||||
onClose={() => sessionManagement.setShowSessionSelector(false)}
|
||||
hasMore={sessionManagement.hasMore}
|
||||
isLoading={sessionManagement.isLoading}
|
||||
onLoadMore={sessionManagement.handleLoadMoreSessions}
|
||||
/>
|
||||
|
||||
<ChatHeader
|
||||
currentSessionTitle={sessionManagement.currentSessionTitle}
|
||||
onLoadSessions={sessionManagement.handleLoadQwenSessions}
|
||||
onNewSession={sessionManagement.handleNewQwenSession}
|
||||
/>
|
||||
|
||||
<div
|
||||
ref={messagesContainerRef}
|
||||
className="chat-messages messages-container flex-1 overflow-y-auto overflow-x-hidden pt-5 pr-5 pl-5 pb-[120px] flex flex-col relative min-w-0 focus:outline-none [&::-webkit-scrollbar]:w-2 [&::-webkit-scrollbar-track]:bg-transparent [&::-webkit-scrollbar-thumb]:bg-white/20 [&::-webkit-scrollbar-thumb]:rounded-sm [&::-webkit-scrollbar-thumb]:hover:bg-white/30 [&>*]:flex [&>*]:gap-0 [&>*]:items-start [&>*]:text-left [&>*]:py-2 [&>*:not(:last-child)]:pb-[8px] [&>*]:flex-col [&>*]:relative [&>*]:animate-[fadeIn_0.2s_ease-in]"
|
||||
>
|
||||
{!hasContent ? (
|
||||
<EmptyState />
|
||||
) : (
|
||||
<>
|
||||
{/* Render all messages and tool calls */}
|
||||
{renderMessages()}
|
||||
{/* Flow-in persistent slot: keeps a small constant height so toggling */}
|
||||
{/* the waiting message doesn't change list height to zero. When */}
|
||||
{/* active, render the waiting message inline (not fixed). */}
|
||||
<div className="waiting-message-slot min-h-[28px]">
|
||||
{messageHandling.isWaitingForResponse &&
|
||||
messageHandling.loadingMessage && (
|
||||
<WaitingMessage
|
||||
loadingMessage={messageHandling.loadingMessage}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div ref={messagesEndRef} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<InputForm
|
||||
inputText={inputText}
|
||||
inputFieldRef={inputFieldRef}
|
||||
isStreaming={messageHandling.isStreaming}
|
||||
isWaitingForResponse={messageHandling.isWaitingForResponse}
|
||||
isComposing={isComposing}
|
||||
editMode={editMode}
|
||||
thinkingEnabled={thinkingEnabled}
|
||||
activeFileName={fileContext.activeFileName}
|
||||
activeSelection={fileContext.activeSelection}
|
||||
skipAutoActiveContext={skipAutoActiveContext}
|
||||
onInputChange={setInputText}
|
||||
onCompositionStart={() => setIsComposing(true)}
|
||||
onCompositionEnd={() => setIsComposing(false)}
|
||||
onKeyDown={() => {}}
|
||||
onSubmit={handleSubmit.handleSubmit}
|
||||
onCancel={handleCancel}
|
||||
onToggleEditMode={handleToggleEditMode}
|
||||
onToggleThinking={handleToggleThinking}
|
||||
onFocusActiveEditor={fileContext.focusActiveEditor}
|
||||
onToggleSkipAutoActiveContext={() =>
|
||||
setSkipAutoActiveContext((v) => !v)
|
||||
}
|
||||
onShowCommandMenu={async () => {
|
||||
if (inputFieldRef.current) {
|
||||
inputFieldRef.current.focus();
|
||||
|
||||
const selection = window.getSelection();
|
||||
let position = { top: 0, left: 0 };
|
||||
|
||||
if (selection && selection.rangeCount > 0) {
|
||||
try {
|
||||
const range = selection.getRangeAt(0);
|
||||
const rangeRect = range.getBoundingClientRect();
|
||||
if (rangeRect.top > 0 && rangeRect.left > 0) {
|
||||
position = {
|
||||
top: rangeRect.top,
|
||||
left: rangeRect.left,
|
||||
};
|
||||
} else {
|
||||
const inputRect =
|
||||
inputFieldRef.current.getBoundingClientRect();
|
||||
position = { top: inputRect.top, left: inputRect.left };
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[App] Error getting cursor position:', error);
|
||||
const inputRect = inputFieldRef.current.getBoundingClientRect();
|
||||
position = { top: inputRect.top, left: inputRect.left };
|
||||
}
|
||||
} else {
|
||||
const inputRect = inputFieldRef.current.getBoundingClientRect();
|
||||
position = { top: inputRect.top, left: inputRect.left };
|
||||
}
|
||||
|
||||
await completion.openCompletion('/', '', position);
|
||||
}
|
||||
}}
|
||||
onAttachContext={handleAttachContextClick}
|
||||
completionIsOpen={completion.isOpen}
|
||||
completionItems={completion.items}
|
||||
onCompletionSelect={handleCompletionSelect}
|
||||
onCompletionClose={completion.closeCompletion}
|
||||
/>
|
||||
|
||||
{permissionRequest && (
|
||||
<PermissionDrawer
|
||||
isOpen={!!permissionRequest}
|
||||
options={permissionRequest.options}
|
||||
toolCall={permissionRequest.toolCall}
|
||||
onResponse={handlePermissionResponse}
|
||||
onClose={() => setPermissionRequest(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
83
packages/vscode-ide-companion/src/webview/MessageHandler.ts
Normal file
83
packages/vscode-ide-companion/src/webview/MessageHandler.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { QwenAgentManager } from '../services/qwenAgentManager.js';
|
||||
import type { ConversationStore } from '../services/conversationStore.js';
|
||||
import { MessageRouter } from './handlers/MessageRouter.js';
|
||||
|
||||
/**
|
||||
* MessageHandler (Refactored Version)
|
||||
* This is a lightweight wrapper class that internally uses MessageRouter and various sub-handlers
|
||||
* Maintains interface compatibility with the original code
|
||||
*/
|
||||
export class MessageHandler {
|
||||
private router: MessageRouter;
|
||||
|
||||
constructor(
|
||||
agentManager: QwenAgentManager,
|
||||
conversationStore: ConversationStore,
|
||||
currentConversationId: string | null,
|
||||
sendToWebView: (message: unknown) => void,
|
||||
) {
|
||||
this.router = new MessageRouter(
|
||||
agentManager,
|
||||
conversationStore,
|
||||
currentConversationId,
|
||||
sendToWebView,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Route messages to the corresponding handler
|
||||
*/
|
||||
async route(message: { type: string; data?: unknown }): Promise<void> {
|
||||
await this.router.route(message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set current session ID
|
||||
*/
|
||||
setCurrentConversationId(id: string | null): void {
|
||||
this.router.setCurrentConversationId(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current session ID
|
||||
*/
|
||||
getCurrentConversationId(): string | null {
|
||||
return this.router.getCurrentConversationId();
|
||||
}
|
||||
|
||||
/**
|
||||
* Set permission handler
|
||||
*/
|
||||
setPermissionHandler(
|
||||
handler: (message: { type: string; data: { optionId: string } }) => void,
|
||||
): void {
|
||||
this.router.setPermissionHandler(handler);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set login handler
|
||||
*/
|
||||
setLoginHandler(handler: () => Promise<void>): void {
|
||||
this.router.setLoginHandler(handler);
|
||||
}
|
||||
|
||||
/**
|
||||
* Append stream content
|
||||
*/
|
||||
appendStreamContent(chunk: string): void {
|
||||
this.router.appendStreamContent(chunk);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if saving checkpoint
|
||||
*/
|
||||
getIsSavingCheckpoint(): boolean {
|
||||
return this.router.getIsSavingCheckpoint();
|
||||
}
|
||||
}
|
||||
385
packages/vscode-ide-companion/src/webview/PanelManager.ts
Normal file
385
packages/vscode-ide-companion/src/webview/PanelManager.ts
Normal file
@@ -0,0 +1,385 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import * as vscode from 'vscode';
|
||||
|
||||
/**
|
||||
* Panel and Tab Manager
|
||||
* Responsible for managing the creation, display, and tab tracking of WebView Panels
|
||||
*/
|
||||
export class PanelManager {
|
||||
private panel: vscode.WebviewPanel | null = null;
|
||||
private panelTab: vscode.Tab | null = null;
|
||||
// Best-effort tracking of the group (by view column) that currently hosts
|
||||
// the Qwen webview. We update this when creating/revealing the panel and
|
||||
// whenever we can capture the Tab from the tab model.
|
||||
private panelGroupViewColumn: vscode.ViewColumn | null = null;
|
||||
|
||||
constructor(
|
||||
private extensionUri: vscode.Uri,
|
||||
private onPanelDispose: () => void,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Get the current Panel
|
||||
*/
|
||||
getPanel(): vscode.WebviewPanel | null {
|
||||
return this.panel;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set Panel (for restoration)
|
||||
*/
|
||||
setPanel(panel: vscode.WebviewPanel): void {
|
||||
console.log('[PanelManager] Setting panel for restoration');
|
||||
this.panel = panel;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create new WebView Panel
|
||||
* @returns Whether it is a newly created Panel
|
||||
*/
|
||||
async createPanel(): Promise<boolean> {
|
||||
if (this.panel) {
|
||||
return false; // Panel already exists
|
||||
}
|
||||
|
||||
// First, check if there's an existing Qwen Code group
|
||||
const existingGroup = this.findExistingQwenCodeGroup();
|
||||
|
||||
if (existingGroup) {
|
||||
// If Qwen Code webview already exists in a locked group, create the new panel in that same group
|
||||
console.log(
|
||||
'[PanelManager] Found existing Qwen Code group, creating panel in same group',
|
||||
);
|
||||
this.panel = vscode.window.createWebviewPanel(
|
||||
'qwenCode.chat',
|
||||
'Qwen Code',
|
||||
{ viewColumn: existingGroup.viewColumn, preserveFocus: false },
|
||||
{
|
||||
enableScripts: true,
|
||||
retainContextWhenHidden: true,
|
||||
localResourceRoots: [
|
||||
vscode.Uri.joinPath(this.extensionUri, 'dist'),
|
||||
vscode.Uri.joinPath(this.extensionUri, 'assets'),
|
||||
],
|
||||
},
|
||||
);
|
||||
// Track the group column hosting this panel
|
||||
this.panelGroupViewColumn = existingGroup.viewColumn;
|
||||
} else {
|
||||
// If no existing Qwen Code group, create a new group to the right of the active editor group
|
||||
try {
|
||||
// Create a new group to the right of the current active group
|
||||
await vscode.commands.executeCommand('workbench.action.newGroupRight');
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
'[PanelManager] Failed to create right editor group (continuing):',
|
||||
error,
|
||||
);
|
||||
// Fallback: create in current group
|
||||
const activeColumn =
|
||||
vscode.window.activeTextEditor?.viewColumn || vscode.ViewColumn.One;
|
||||
this.panel = vscode.window.createWebviewPanel(
|
||||
'qwenCode.chat',
|
||||
'Qwen Code',
|
||||
{ viewColumn: activeColumn, preserveFocus: false },
|
||||
{
|
||||
enableScripts: true,
|
||||
retainContextWhenHidden: true,
|
||||
localResourceRoots: [
|
||||
vscode.Uri.joinPath(this.extensionUri, 'dist'),
|
||||
vscode.Uri.joinPath(this.extensionUri, 'assets'),
|
||||
],
|
||||
},
|
||||
);
|
||||
// Lock the group after creation
|
||||
await this.autoLockEditorGroup();
|
||||
return true;
|
||||
}
|
||||
|
||||
// Get the new group's view column (should be the active one after creating right)
|
||||
const newGroupColumn = vscode.window.tabGroups.activeTabGroup.viewColumn;
|
||||
|
||||
this.panel = vscode.window.createWebviewPanel(
|
||||
'qwenCode.chat',
|
||||
'Qwen Code',
|
||||
{ viewColumn: newGroupColumn, preserveFocus: false },
|
||||
{
|
||||
enableScripts: true,
|
||||
retainContextWhenHidden: true,
|
||||
localResourceRoots: [
|
||||
vscode.Uri.joinPath(this.extensionUri, 'dist'),
|
||||
vscode.Uri.joinPath(this.extensionUri, 'assets'),
|
||||
],
|
||||
},
|
||||
);
|
||||
|
||||
// Lock the group after creation
|
||||
await this.autoLockEditorGroup();
|
||||
|
||||
// Track the newly created group's column
|
||||
this.panelGroupViewColumn = newGroupColumn;
|
||||
}
|
||||
|
||||
// Set panel icon to Qwen logo
|
||||
this.panel.iconPath = vscode.Uri.joinPath(
|
||||
this.extensionUri,
|
||||
'assets',
|
||||
'icon.png',
|
||||
);
|
||||
|
||||
// Try to capture Tab info shortly after creation so we can track the
|
||||
// precise group even if the user later drags the tab between groups.
|
||||
this.captureTab();
|
||||
|
||||
return true; // New panel created
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the group and view column where the existing Qwen Code webview is located
|
||||
* @returns The found group and view column, or undefined if not found
|
||||
*/
|
||||
private findExistingQwenCodeGroup():
|
||||
| { group: vscode.TabGroup; viewColumn: vscode.ViewColumn }
|
||||
| undefined {
|
||||
for (const group of vscode.window.tabGroups.all) {
|
||||
for (const tab of group.tabs) {
|
||||
const input: unknown = (tab as { input?: unknown }).input;
|
||||
const isWebviewInput = (inp: unknown): inp is { viewType: string } =>
|
||||
!!inp && typeof inp === 'object' && 'viewType' in inp;
|
||||
|
||||
if (
|
||||
isWebviewInput(input) &&
|
||||
input.viewType === 'mainThreadWebview-qwenCode.chat'
|
||||
) {
|
||||
// Found an existing Qwen Code tab
|
||||
console.log('[PanelManager] Found existing Qwen Code group:', {
|
||||
viewColumn: group.viewColumn,
|
||||
tabCount: group.tabs.length,
|
||||
isActive: group.isActive,
|
||||
});
|
||||
return {
|
||||
group,
|
||||
viewColumn: group.viewColumn,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-lock editor group (only called when creating a new Panel)
|
||||
* After creating/revealing the WebviewPanel, lock the active editor group so
|
||||
* the group stays dedicated (users can still unlock manually). We still
|
||||
* temporarily unlock before creation to allow adding tabs to an existing
|
||||
* group; this method restores the locked state afterwards.
|
||||
*/
|
||||
async autoLockEditorGroup(): Promise<void> {
|
||||
if (!this.panel) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// The newly created panel is focused (preserveFocus: false), so this
|
||||
// locks the correct, active editor group.
|
||||
await vscode.commands.executeCommand('workbench.action.lockEditorGroup');
|
||||
console.log('[PanelManager] Group locked after panel creation');
|
||||
} catch (error) {
|
||||
console.warn('[PanelManager] Failed to lock editor group:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show Panel (reveal if exists, otherwise do nothing)
|
||||
* @param preserveFocus Whether to preserve focus
|
||||
*/
|
||||
revealPanel(preserveFocus: boolean = true): void {
|
||||
if (this.panel) {
|
||||
// Prefer revealing in the currently tracked group to avoid reflowing groups.
|
||||
const trackedColumn = (
|
||||
this.panelTab as unknown as {
|
||||
group?: { viewColumn?: vscode.ViewColumn };
|
||||
}
|
||||
)?.group?.viewColumn as vscode.ViewColumn | undefined;
|
||||
const targetColumn: vscode.ViewColumn =
|
||||
trackedColumn ??
|
||||
this.panelGroupViewColumn ??
|
||||
vscode.window.tabGroups.activeTabGroup.viewColumn;
|
||||
this.panel.reveal(targetColumn, preserveFocus);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Capture the Tab corresponding to the WebView Panel
|
||||
* Used for tracking and managing Tab state
|
||||
*/
|
||||
captureTab(): void {
|
||||
if (!this.panel) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Defer slightly so the tab model is updated after create/reveal
|
||||
setTimeout(() => {
|
||||
const allTabs = vscode.window.tabGroups.all.flatMap((g) => g.tabs);
|
||||
const match = allTabs.find((t) => {
|
||||
// Type guard for webview tab input
|
||||
const input: unknown = (t as { input?: unknown }).input;
|
||||
const isWebviewInput = (inp: unknown): inp is { viewType: string } =>
|
||||
!!inp && typeof inp === 'object' && 'viewType' in inp;
|
||||
const isWebview = isWebviewInput(input);
|
||||
const sameViewType = isWebview && input.viewType === 'qwenCode.chat';
|
||||
const sameLabel = t.label === this.panel!.title;
|
||||
return !!(sameViewType || sameLabel);
|
||||
});
|
||||
this.panelTab = match ?? null;
|
||||
// Update last-known group column if we can read it from the captured tab
|
||||
try {
|
||||
const groupViewColumn = (
|
||||
this.panelTab as unknown as {
|
||||
group?: { viewColumn?: vscode.ViewColumn };
|
||||
}
|
||||
)?.group?.viewColumn;
|
||||
if (groupViewColumn !== null) {
|
||||
this.panelGroupViewColumn = groupViewColumn as vscode.ViewColumn;
|
||||
}
|
||||
} catch {
|
||||
// Best effort only; ignore if the API shape differs
|
||||
}
|
||||
}, 50);
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the dispose event handler for the Panel
|
||||
* @param disposables Array used to store Disposable objects
|
||||
*/
|
||||
registerDisposeHandler(disposables: vscode.Disposable[]): void {
|
||||
if (!this.panel) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.panel.onDidDispose(
|
||||
() => {
|
||||
// Capture the group we intend to clean up before we clear fields
|
||||
const targetColumn: vscode.ViewColumn | null =
|
||||
// Prefer the group from the captured tab if available
|
||||
((
|
||||
this.panelTab as unknown as {
|
||||
group?: { viewColumn?: vscode.ViewColumn };
|
||||
}
|
||||
)?.group?.viewColumn as vscode.ViewColumn | undefined) ??
|
||||
// Fall back to our last-known group column
|
||||
this.panelGroupViewColumn ??
|
||||
null;
|
||||
|
||||
this.panel = null;
|
||||
this.panelTab = null;
|
||||
this.onPanelDispose();
|
||||
|
||||
// After VS Code updates its tab model, check if that group is now
|
||||
// empty (and typically locked for Qwen). If so, close the group to
|
||||
// avoid leaving an empty locked column when the user closes Qwen.
|
||||
if (targetColumn !== null) {
|
||||
const column: vscode.ViewColumn = targetColumn;
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
const groups = vscode.window.tabGroups.all;
|
||||
const group = groups.find((g) => g.viewColumn === column);
|
||||
// If the group that hosted Qwen is now empty, close it to avoid
|
||||
// leaving an empty locked column around. VS Code's stable API
|
||||
// does not expose the lock state on TabGroup, so we only check
|
||||
// for emptiness here.
|
||||
if (group && group.tabs.length === 0) {
|
||||
// Focus the group we want to close
|
||||
await this.focusGroupByColumn(column);
|
||||
// Try closeGroup first; fall back to removeActiveEditorGroup
|
||||
try {
|
||||
await vscode.commands.executeCommand(
|
||||
'workbench.action.closeGroup',
|
||||
);
|
||||
} catch {
|
||||
try {
|
||||
await vscode.commands.executeCommand(
|
||||
'workbench.action.removeActiveEditorGroup',
|
||||
);
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
'[PanelManager] Failed to close empty group after Qwen panel disposed:',
|
||||
err,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
'[PanelManager] Error while trying to close empty Qwen group:',
|
||||
err,
|
||||
);
|
||||
}
|
||||
}, 50);
|
||||
}
|
||||
},
|
||||
null,
|
||||
disposables,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Focus the editor group at the given view column by stepping left/right.
|
||||
* This avoids depending on Nth-group focus commands that may not exist.
|
||||
*/
|
||||
private async focusGroupByColumn(target: vscode.ViewColumn): Promise<void> {
|
||||
const maxHops = 20; // safety guard for unusual layouts
|
||||
let hops = 0;
|
||||
while (
|
||||
vscode.window.tabGroups.activeTabGroup.viewColumn !== target &&
|
||||
hops < maxHops
|
||||
) {
|
||||
const current = vscode.window.tabGroups.activeTabGroup.viewColumn;
|
||||
if (current < target) {
|
||||
await vscode.commands.executeCommand(
|
||||
'workbench.action.focusRightGroup',
|
||||
);
|
||||
} else if (current > target) {
|
||||
await vscode.commands.executeCommand('workbench.action.focusLeftGroup');
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
hops++;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the view state change event handler
|
||||
* @param disposables Array used to store Disposable objects
|
||||
*/
|
||||
registerViewStateChangeHandler(disposables: vscode.Disposable[]): void {
|
||||
if (!this.panel) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.panel.onDidChangeViewState(
|
||||
() => {
|
||||
if (this.panel && this.panel.visible) {
|
||||
this.captureTab();
|
||||
}
|
||||
},
|
||||
null,
|
||||
disposables,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispose Panel
|
||||
*/
|
||||
dispose(): void {
|
||||
this.panel?.dispose();
|
||||
this.panel = null;
|
||||
this.panelTab = null;
|
||||
}
|
||||
}
|
||||
50
packages/vscode-ide-companion/src/webview/WebViewContent.ts
Normal file
50
packages/vscode-ide-companion/src/webview/WebViewContent.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import * as vscode from 'vscode';
|
||||
import { escapeHtml } from './utils/webviewUtils.js';
|
||||
|
||||
/**
|
||||
* WebView HTML Content Generator
|
||||
* Responsible for generating the HTML content of the WebView
|
||||
*/
|
||||
export class WebViewContent {
|
||||
/**
|
||||
* Generate HTML content for the WebView
|
||||
* @param panel WebView Panel
|
||||
* @param extensionUri Extension URI
|
||||
* @returns HTML string
|
||||
*/
|
||||
static generate(
|
||||
panel: vscode.WebviewPanel,
|
||||
extensionUri: vscode.Uri,
|
||||
): string {
|
||||
const scriptUri = panel.webview.asWebviewUri(
|
||||
vscode.Uri.joinPath(extensionUri, 'dist', 'webview.js'),
|
||||
);
|
||||
|
||||
// Convert extension URI for webview access - this allows frontend to construct resource paths
|
||||
const extensionUriForWebview = panel.webview.asWebviewUri(extensionUri);
|
||||
|
||||
// Escape URI for HTML to prevent potential injection attacks
|
||||
const safeExtensionUri = escapeHtml(extensionUriForWebview.toString());
|
||||
const safeScriptUri = escapeHtml(scriptUri.toString());
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; img-src ${panel.webview.cspSource}; script-src ${panel.webview.cspSource}; style-src ${panel.webview.cspSource} 'unsafe-inline';">
|
||||
<title>Qwen Code</title>
|
||||
</head>
|
||||
<body data-extension-uri="${safeExtensionUri}">
|
||||
<div id="root"></div>
|
||||
<script src="${safeScriptUri}"></script>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
}
|
||||
1225
packages/vscode-ide-companion/src/webview/WebViewProvider.ts
Normal file
1225
packages/vscode-ide-companion/src/webview/WebViewProvider.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,312 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { useEffect, useState, useRef } from 'react';
|
||||
import type { PermissionOption, ToolCall } from './PermissionRequest.js';
|
||||
|
||||
interface PermissionDrawerProps {
|
||||
isOpen: boolean;
|
||||
options: PermissionOption[];
|
||||
toolCall: ToolCall;
|
||||
onResponse: (optionId: string) => void;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
export const PermissionDrawer: React.FC<PermissionDrawerProps> = ({
|
||||
isOpen,
|
||||
options,
|
||||
toolCall,
|
||||
onResponse,
|
||||
onClose,
|
||||
}) => {
|
||||
const [focusedIndex, setFocusedIndex] = useState(0);
|
||||
const [customMessage, setCustomMessage] = useState('');
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
// Correct the ref type for custom input to HTMLInputElement to avoid subsequent forced casting
|
||||
const customInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
console.log('PermissionDrawer rendered with isOpen:', isOpen, toolCall);
|
||||
// Prefer file name from locations, fall back to content[].path if present
|
||||
const getAffectedFileName = (): string => {
|
||||
const fromLocations = toolCall.locations?.[0]?.path;
|
||||
if (fromLocations) {
|
||||
return fromLocations.split('/').pop() || fromLocations;
|
||||
}
|
||||
// Some tool calls (e.g. write/edit with diff content) only include path in content
|
||||
const fromContent = Array.isArray(toolCall.content)
|
||||
? (
|
||||
toolCall.content.find(
|
||||
(c: unknown) =>
|
||||
typeof c === 'object' &&
|
||||
c !== null &&
|
||||
'path' in (c as Record<string, unknown>),
|
||||
) as { path?: unknown } | undefined
|
||||
)?.path
|
||||
: undefined;
|
||||
if (typeof fromContent === 'string' && fromContent.length > 0) {
|
||||
return fromContent.split('/').pop() || fromContent;
|
||||
}
|
||||
return 'file';
|
||||
};
|
||||
|
||||
// Get the title for the permission request
|
||||
const getTitle = () => {
|
||||
if (toolCall.kind === 'edit' || toolCall.kind === 'write') {
|
||||
const fileName = getAffectedFileName();
|
||||
return (
|
||||
<>
|
||||
Make this edit to{' '}
|
||||
<span className="font-mono text-[var(--app-primary-foreground)]">
|
||||
{fileName}
|
||||
</span>
|
||||
?
|
||||
</>
|
||||
);
|
||||
}
|
||||
if (toolCall.kind === 'execute' || toolCall.kind === 'bash') {
|
||||
return 'Allow this bash command?';
|
||||
}
|
||||
if (toolCall.kind === 'read') {
|
||||
const fileName = getAffectedFileName();
|
||||
return (
|
||||
<>
|
||||
Allow read from{' '}
|
||||
<span className="font-mono text-[var(--app-primary-foreground)]">
|
||||
{fileName}
|
||||
</span>
|
||||
?
|
||||
</>
|
||||
);
|
||||
}
|
||||
return toolCall.title || 'Permission Required';
|
||||
};
|
||||
|
||||
// Handle keyboard navigation
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (!isOpen) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Number keys 1-9 for quick select
|
||||
const numMatch = e.key.match(/^[1-9]$/);
|
||||
if (
|
||||
numMatch &&
|
||||
!customInputRef.current?.contains(document.activeElement)
|
||||
) {
|
||||
const index = parseInt(e.key, 10) - 1;
|
||||
if (index < options.length) {
|
||||
e.preventDefault();
|
||||
onResponse(options[index].optionId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Arrow keys for navigation
|
||||
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
const totalItems = options.length + 1; // +1 for custom input
|
||||
if (e.key === 'ArrowDown') {
|
||||
setFocusedIndex((prev) => (prev + 1) % totalItems);
|
||||
} else {
|
||||
setFocusedIndex((prev) => (prev - 1 + totalItems) % totalItems);
|
||||
}
|
||||
}
|
||||
|
||||
// Enter to select
|
||||
if (
|
||||
e.key === 'Enter' &&
|
||||
!customInputRef.current?.contains(document.activeElement)
|
||||
) {
|
||||
e.preventDefault();
|
||||
if (focusedIndex < options.length) {
|
||||
onResponse(options[focusedIndex].optionId);
|
||||
}
|
||||
}
|
||||
|
||||
// Escape to cancel permission and close (align with CLI behavior)
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
const rejectOptionId =
|
||||
options.find((o) => o.kind.includes('reject'))?.optionId ||
|
||||
options.find((o) => o.optionId === 'cancel')?.optionId ||
|
||||
'cancel';
|
||||
onResponse(rejectOptionId);
|
||||
if (onClose) {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [isOpen, options, onResponse, onClose, focusedIndex]);
|
||||
|
||||
// Focus container when opened
|
||||
useEffect(() => {
|
||||
if (isOpen && containerRef.current) {
|
||||
containerRef.current.focus();
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
// Reset focus to the first option when the drawer opens or the options change
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setFocusedIndex(0);
|
||||
}
|
||||
}, [isOpen, options.length]);
|
||||
|
||||
if (!isOpen) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-x-0 bottom-0 z-[1000] p-2">
|
||||
{/* Main container */}
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="relative flex flex-col rounded-large border p-2 outline-none animate-slide-up"
|
||||
style={{
|
||||
backgroundColor: 'var(--app-input-secondary-background)',
|
||||
borderColor: 'var(--app-input-border)',
|
||||
}}
|
||||
tabIndex={0}
|
||||
data-focused-index={focusedIndex}
|
||||
>
|
||||
{/* Background layer */}
|
||||
<div
|
||||
className="p-2 absolute inset-0 rounded-large"
|
||||
style={{ backgroundColor: 'var(--app-input-background)' }}
|
||||
/>
|
||||
|
||||
{/* Title + Description (from toolCall.title) */}
|
||||
<div className="relative z-[1] text-[1.1em] text-[var(--app-primary-foreground)] flex flex-col min-h-0">
|
||||
<div className="font-bold text-[var(--app-primary-foreground)] mb-0.5">
|
||||
{getTitle()}
|
||||
</div>
|
||||
{(toolCall.kind === 'edit' ||
|
||||
toolCall.kind === 'write' ||
|
||||
toolCall.kind === 'read' ||
|
||||
toolCall.kind === 'execute' ||
|
||||
toolCall.kind === 'bash') &&
|
||||
toolCall.title && (
|
||||
<div
|
||||
/* 13px, normal font weight; normal whitespace wrapping + long word breaking; maximum 3 lines with overflow ellipsis */
|
||||
className="text-[13px] font-normal text-[var(--app-secondary-foreground)] opacity-90 font-mono whitespace-normal break-words q-line-clamp-3 mb-2"
|
||||
style={{
|
||||
fontSize: '.9em',
|
||||
color: 'var(--app-secondary-foreground)',
|
||||
marginBottom: '6px',
|
||||
}}
|
||||
title={toolCall.title}
|
||||
>
|
||||
{toolCall.title}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Options */}
|
||||
<div className="relative z-[1] flex flex-col gap-1 pb-1">
|
||||
{options.map((option, index) => {
|
||||
const isFocused = focusedIndex === index;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={option.optionId}
|
||||
className={`flex items-center gap-2 px-2 py-1.5 text-left w-full box-border rounded-[4px] border-0 shadow-[inset_0_0_0_1px_var(--app-transparent-inner-border)] transition-colors duration-150 text-[var(--app-primary-foreground)] hover:bg-[var(--app-button-background)] ${
|
||||
isFocused
|
||||
? 'text-[var(--app-list-active-foreground)] bg-[var(--app-list-active-background)] hover:text-[var(--app-button-foreground)] hover:font-bold hover:relative hover:border-0'
|
||||
: 'hover:bg-[var(--app-button-background)] hover:text-[var(--app-button-foreground)] hover:font-bold hover:relative hover:border-0'
|
||||
}`}
|
||||
onClick={() => onResponse(option.optionId)}
|
||||
onMouseEnter={() => setFocusedIndex(index)}
|
||||
>
|
||||
{/* Number badge */}
|
||||
<span className="inline-flex items-center justify-center min-w-[10px] h-5 font-semibold opacity-60">
|
||||
{index + 1}
|
||||
</span>
|
||||
{/* Option text */}
|
||||
<span className="font-semibold">{option.name}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Custom message input (extracted component) */}
|
||||
{(() => {
|
||||
const isFocused = focusedIndex === options.length;
|
||||
const rejectOptionId = options.find((o) =>
|
||||
o.kind.includes('reject'),
|
||||
)?.optionId;
|
||||
return (
|
||||
<CustomMessageInputRow
|
||||
isFocused={isFocused}
|
||||
customMessage={customMessage}
|
||||
setCustomMessage={setCustomMessage}
|
||||
onFocusRow={() => setFocusedIndex(options.length)}
|
||||
onSubmitReject={() => {
|
||||
if (rejectOptionId) {
|
||||
onResponse(rejectOptionId);
|
||||
}
|
||||
}}
|
||||
inputRef={customInputRef}
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Moved slide-up keyframes to Tailwind theme (tailwind.config.js) */}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* CustomMessageInputRow: Reusable custom input row component (without hooks)
|
||||
*/
|
||||
interface CustomMessageInputRowProps {
|
||||
isFocused: boolean;
|
||||
customMessage: string;
|
||||
setCustomMessage: (val: string) => void;
|
||||
onFocusRow: () => void; // Set focus when mouse enters or input box is focused
|
||||
onSubmitReject: () => void; // Triggered when Enter is pressed (selecting reject option)
|
||||
inputRef: React.RefObject<HTMLInputElement | null>;
|
||||
}
|
||||
|
||||
const CustomMessageInputRow: React.FC<CustomMessageInputRowProps> = ({
|
||||
isFocused,
|
||||
customMessage,
|
||||
setCustomMessage,
|
||||
onFocusRow,
|
||||
onSubmitReject,
|
||||
inputRef,
|
||||
}) => (
|
||||
<div
|
||||
className={`flex items-center gap-2 px-2 py-1.5 text-left w-full box-border rounded-[4px] border-0 shadow-[inset_0_0_0_1px_var(--app-transparent-inner-border)] cursor-text text-[var(--app-primary-foreground)] ${
|
||||
isFocused ? 'text-[var(--app-list-active-foreground)]' : ''
|
||||
}`}
|
||||
onMouseEnter={onFocusRow}
|
||||
onClick={() => inputRef.current?.focus()}
|
||||
>
|
||||
<input
|
||||
ref={inputRef as React.LegacyRef<HTMLInputElement> | undefined}
|
||||
type="text"
|
||||
placeholder="Tell Qwen what to do instead"
|
||||
spellCheck={false}
|
||||
className="flex-1 bg-transparent border-0 outline-none text-sm placeholder:opacity-70"
|
||||
style={{ color: 'var(--app-input-foreground)' }}
|
||||
value={customMessage}
|
||||
onChange={(e) => setCustomMessage(e.target.value)}
|
||||
onFocus={onFocusRow}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey && customMessage.trim()) {
|
||||
e.preventDefault();
|
||||
onSubmitReject();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
export interface PermissionOption {
|
||||
name: string;
|
||||
kind: string;
|
||||
optionId: string;
|
||||
}
|
||||
|
||||
export interface ToolCall {
|
||||
title?: string;
|
||||
kind?: string;
|
||||
toolCallId?: string;
|
||||
rawInput?: {
|
||||
command?: string;
|
||||
description?: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
content?: Array<{
|
||||
type: string;
|
||||
[key: string]: unknown;
|
||||
}>;
|
||||
locations?: Array<{
|
||||
path: string;
|
||||
line?: number | null;
|
||||
}>;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
export interface PermissionRequestProps {
|
||||
options: PermissionOption[];
|
||||
toolCall: ToolCall;
|
||||
onResponse: (optionId: string) => void;
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*
|
||||
* Edit mode related icons
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import type { IconProps } from './types.js';
|
||||
|
||||
/**
|
||||
* Edit pencil icon (16x16)
|
||||
* Used for "Ask before edits" mode
|
||||
*/
|
||||
export const EditPencilIcon: React.FC<IconProps> = ({
|
||||
size = 16,
|
||||
className,
|
||||
...props
|
||||
}) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 16 16"
|
||||
fill="currentColor"
|
||||
width={size}
|
||||
height={size}
|
||||
className={className}
|
||||
aria-hidden="true"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M11.013 2.513a1.75 1.75 0 0 1 2.475 2.474L6.226 12.25a2.751 2.751 0 0 1-.892.596l-2.047.848a.75.75 0 0 1-.98-.98l.848-2.047a2.75 2.75 0 0 1 .596-.892l7.262-7.261Z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
/**
|
||||
* Auto/fast-forward icon (16x16)
|
||||
* Used for "Edit automatically" mode
|
||||
*/
|
||||
export const AutoEditIcon: React.FC<IconProps> = ({
|
||||
size = 16,
|
||||
className,
|
||||
...props
|
||||
}) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 16 16"
|
||||
fill="currentColor"
|
||||
width={size}
|
||||
height={size}
|
||||
className={className}
|
||||
aria-hidden="true"
|
||||
{...props}
|
||||
>
|
||||
<path d="M2.53 3.956A1 1 0 0 0 1 4.804v6.392a1 1 0 0 0 1.53.848l5.113-3.196c.16-.1.279-.233.357-.383v2.73a1 1 0 0 0 1.53.849l5.113-3.196a1 1 0 0 0 0-1.696L9.53 3.956A1 1 0 0 0 8 4.804v2.731a.992.992 0 0 0-.357-.383L2.53 3.956Z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
/**
|
||||
* Plan mode/bars icon (16x16)
|
||||
* Used for "Plan mode"
|
||||
*/
|
||||
export const PlanModeIcon: React.FC<IconProps> = ({
|
||||
size = 16,
|
||||
className,
|
||||
...props
|
||||
}) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 16 16"
|
||||
fill="currentColor"
|
||||
width={size}
|
||||
height={size}
|
||||
className={className}
|
||||
aria-hidden="true"
|
||||
{...props}
|
||||
>
|
||||
<path d="M4.5 2a.5.5 0 0 0-.5.5v11a.5.5 0 0 0 .5.5h1a.5.5 0 0 0 .5-.5v-11a.5.5 0 0 0-.5-.5h-1ZM10.5 2a.5.5 0 0 0-.5.5v11a.5.5 0 0 0 .5.5h1a.5.5 0 0 0 .5-.5v-11a.5.5 0 0 0-.5-.5h-1Z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
/**
|
||||
* Code brackets icon (20x20)
|
||||
* Used for active file indicator
|
||||
*/
|
||||
export const CodeBracketsIcon: React.FC<IconProps> = ({
|
||||
size = 20,
|
||||
className,
|
||||
...props
|
||||
}) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
width={size}
|
||||
height={size}
|
||||
className={className}
|
||||
aria-hidden="true"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M6.28 5.22a.75.75 0 0 1 0 1.06L2.56 10l3.72 3.72a.75.75 0 0 1-1.06 1.06L.97 10.53a.75.75 0 0 1 0-1.06l4.25-4.25a.75.75 0 0 1 1.06 0Zm7.44 0a.75.75 0 0 1 1.06 0l4.25 4.25a.75.75 0 0 1 0 1.06l-4.25 4.25a.75.75 0 0 1-1.06-1.06L17.44 10l-3.72-3.72a.75.75 0 0 1 0-1.06ZM11.377 2.011a.75.75 0 0 1 .612.867l-2.5 14.5a.75.75 0 0 1-1.478-.255l2.5-14.5a.75.75 0 0 1 .866-.612Z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
/**
|
||||
* Hide context (eye slash) icon (20x20)
|
||||
* Used to indicate the active selection will NOT be auto-loaded into context
|
||||
*/
|
||||
export const HideContextIcon: React.FC<IconProps> = ({
|
||||
size = 20,
|
||||
className,
|
||||
...props
|
||||
}) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
width={size}
|
||||
height={size}
|
||||
className={className}
|
||||
aria-hidden="true"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M3.28 2.22a.75.75 0 0 0-1.06 1.06l14.5 14.5a.75.75 0 1 0 1.06-1.06l-1.745-1.745a10.029 10.029 0 0 0 3.3-4.38 1.651 1.651 0 0 0 0-1.185A10.004 10.004 0 0 0 9.999 3a9.956 9.956 0 0 0-4.744 1.194L3.28 2.22ZM7.752 6.69l1.092 1.092a2.5 2.5 0 0 1 3.374 3.373l1.091 1.092a4 4 0 0 0-5.557-5.557Z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
<path d="m10.748 13.93 2.523 2.523a9.987 9.987 0 0 1-3.27.547c-4.258 0-7.894-2.66-9.337-6.41a1.651 1.651 0 0 1 0-1.186A10.007 10.007 0 0 1 2.839 6.02L6.07 9.252a4 4 0 0 0 4.678 4.678Z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
/**
|
||||
* Slash command icon (20x20)
|
||||
* Used for command menu button
|
||||
*/
|
||||
export const SlashCommandIcon: React.FC<IconProps> = ({
|
||||
size = 20,
|
||||
className,
|
||||
...props
|
||||
}) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
width={size}
|
||||
height={size}
|
||||
className={className}
|
||||
aria-hidden="true"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M12.528 3.047a.75.75 0 0 1 .449.961L8.433 16.504a.75.75 0 1 1-1.41-.512l4.544-12.496a.75.75 0 0 1 .961-.449Z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
/**
|
||||
* Link/attachment icon (20x20)
|
||||
* Used for attach context button
|
||||
*/
|
||||
export const LinkIcon: React.FC<IconProps> = ({
|
||||
size = 20,
|
||||
className,
|
||||
...props
|
||||
}) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
width={size}
|
||||
height={size}
|
||||
className={className}
|
||||
aria-hidden="true"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M15.621 4.379a3 3 0 0 0-4.242 0l-7 7a3 3 0 0 0 4.241 4.243h.001l.497-.5a.75.75 0 0 1 1.064 1.057l-.498.501-.002.002a4.5 4.5 0 0 1-6.364-6.364l7-7a4.5 4.5 0 0 1 6.368 6.36l-3.455 3.553A2.625 2.625 0 1 1 9.52 9.52l3.45-3.451a.75.75 0 1 1 1.061 1.06l-3.45 3.451a1.125 1.125 0 0 0 1.587 1.595l3.454-3.553a3 3 0 0 0 0-4.242Z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
/**
|
||||
* Open diff icon (16x16)
|
||||
* Used for opening diff in VS Code
|
||||
*/
|
||||
export const OpenDiffIcon: React.FC<IconProps> = ({
|
||||
size = 16,
|
||||
className,
|
||||
...props
|
||||
}) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 16 16"
|
||||
fill="currentColor"
|
||||
width={size}
|
||||
height={size}
|
||||
className={className}
|
||||
aria-hidden="true"
|
||||
{...props}
|
||||
>
|
||||
<path d="M13.5 7l-4-4v3h-6v2h6v3l4-4z" />
|
||||
</svg>
|
||||
);
|
||||
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*
|
||||
* File and document related icons
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import type { IconProps } from './types.js';
|
||||
|
||||
/**
|
||||
* File document icon (16x16)
|
||||
* Used for file completion menu
|
||||
*/
|
||||
export const FileIcon: React.FC<IconProps> = ({
|
||||
size = 16,
|
||||
className,
|
||||
...props
|
||||
}) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 16 16"
|
||||
fill="currentColor"
|
||||
width={size}
|
||||
height={size}
|
||||
className={className}
|
||||
aria-hidden="true"
|
||||
{...props}
|
||||
>
|
||||
<path d="M9 2H4a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2V7l-5-5zm3 7V3.5L10.5 2H10v3a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1V2H4a1 1 0 0 0-1 1v8a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1zM6 3h3v2H6V3z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const FileListIcon: React.FC<IconProps> = ({
|
||||
size = 16,
|
||||
className,
|
||||
...props
|
||||
}) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 16 16"
|
||||
fill="currentColor"
|
||||
width={size}
|
||||
height={size}
|
||||
className={className}
|
||||
aria-hidden="true"
|
||||
{...props}
|
||||
>
|
||||
<path d="M5 3.5a.5.5 0 0 1 .5-.5h4a.5.5 0 0 1 0 1h-4a.5.5 0 0 1-.5-.5Zm0 2a.5.5 0 0 1 .5-.5h7a.5.5 0 0 1 0 1h-7a.5.5 0 0 1-.5-.5Zm0 2a.5.5 0 0 1 .5-.5h7a.5.5 0 0 1 0 1h-7a.5.5 0 0 1-.5-.5Zm0 2a.5.5 0 0 1 .5-.5h7a.5.5 0 0 1 0 1h-7a.5.5 0 0 1-.5-.5Z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
/**
|
||||
* Save document icon (16x16)
|
||||
* Used for save session button
|
||||
*/
|
||||
export const SaveDocumentIcon: React.FC<IconProps> = ({
|
||||
size = 16,
|
||||
className,
|
||||
...props
|
||||
}) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
width={size}
|
||||
height={size}
|
||||
className={className}
|
||||
aria-hidden="true"
|
||||
{...props}
|
||||
>
|
||||
<path d="M2.66663 2.66663H10.6666L13.3333 5.33329V13.3333H2.66663V2.66663Z" />
|
||||
<path d="M8 10.6666V8M8 8V5.33329M8 8H10.6666M8 8H5.33329" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
/**
|
||||
* Folder icon (16x16)
|
||||
* Useful for directory entries in completion lists
|
||||
*/
|
||||
export const FolderIcon: React.FC<IconProps> = ({
|
||||
size = 16,
|
||||
className,
|
||||
...props
|
||||
}) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 16 16"
|
||||
fill="currentColor"
|
||||
width={size}
|
||||
height={size}
|
||||
className={className}
|
||||
aria-hidden="true"
|
||||
{...props}
|
||||
>
|
||||
<path d="M1.5 3A1.5 1.5 0 0 1 3 1.5h3.086a1.5 1.5 0 0 1 1.06.44L8.5 3H13A1.5 1.5 0 0 1 14.5 4.5v7A1.5 1.5 0 0 1 13 13H3A1.5 1.5 0 0 1 1.5 11.5v-8Z" />
|
||||
</svg>
|
||||
);
|
||||
@@ -0,0 +1,212 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*
|
||||
* Navigation and action icons
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import type { IconProps } from './types.js';
|
||||
|
||||
/**
|
||||
* Chevron down icon (20x20)
|
||||
* Used for dropdown arrows
|
||||
*/
|
||||
export const ChevronDownIcon: React.FC<IconProps> = ({
|
||||
size = 20,
|
||||
className,
|
||||
...props
|
||||
}) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
width={size}
|
||||
height={size}
|
||||
className={className}
|
||||
aria-hidden="true"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M5.22 8.22a.75.75 0 0 1 1.06 0L10 11.94l3.72-3.72a.75.75 0 1 1 1.06 1.06l-4.25 4.25a.75.75 0 0 1-1.06 0L5.22 9.28a.75.75 0 0 1 0-1.06Z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
/**
|
||||
* Plus icon (20x20)
|
||||
* Used for new session button
|
||||
*/
|
||||
export const PlusIcon: React.FC<IconProps> = ({
|
||||
size = 20,
|
||||
className,
|
||||
...props
|
||||
}) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
width={size}
|
||||
height={size}
|
||||
className={className}
|
||||
aria-hidden="true"
|
||||
{...props}
|
||||
>
|
||||
<path d="M10.75 4.75a.75.75 0 0 0-1.5 0v4.5h-4.5a.75.75 0 0 0 0 1.5h4.5v4.5a.75.75 0 0 0 1.5 0v-4.5h4.5a.75.75 0 0 0 0-1.5h-4.5v-4.5Z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
/**
|
||||
* Small plus icon (16x16)
|
||||
* Used for default attachment type
|
||||
*/
|
||||
export const PlusSmallIcon: React.FC<IconProps> = ({
|
||||
size = 16,
|
||||
className,
|
||||
...props
|
||||
}) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 16 16"
|
||||
fill="currentColor"
|
||||
width={size}
|
||||
height={size}
|
||||
className={className}
|
||||
aria-hidden="true"
|
||||
{...props}
|
||||
>
|
||||
<path d="M8 2a.5.5 0 0 1 .5.5V5h2.5a.5.5 0 0 1 0 1H8.5v2.5a.5.5 0 0 1-1 0V6H5a.5.5 0 0 1 0-1h2.5V2.5A.5.5 0 0 1 8 2Z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
/**
|
||||
* Arrow up icon (20x20)
|
||||
* Used for send message button
|
||||
*/
|
||||
export const ArrowUpIcon: React.FC<IconProps> = ({
|
||||
size = 20,
|
||||
className,
|
||||
...props
|
||||
}) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
width={size}
|
||||
height={size}
|
||||
className={className}
|
||||
aria-hidden="true"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M10 17a.75.75 0 0 1-.75-.75V5.612L5.29 9.77a.75.75 0 0 1-1.08-1.04l5.25-5.5a.75.75 0 0 1 1.08 0l5.25 5.5a.75.75 0 1 1-1.08 1.04l-3.96-4.158V16.25A.75.75 0 0 1 10 17Z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
/**
|
||||
* Close X icon (14x14)
|
||||
* Used for close buttons in banners and dialogs
|
||||
*/
|
||||
export const CloseIcon: React.FC<IconProps> = ({
|
||||
size = 14,
|
||||
className,
|
||||
...props
|
||||
}) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 14 14"
|
||||
fill="none"
|
||||
width={size}
|
||||
height={size}
|
||||
className={className}
|
||||
aria-hidden="true"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
d="M1 1L13 13M1 13L13 1"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const CloseSmallIcon: React.FC<IconProps> = ({
|
||||
size = 16,
|
||||
className,
|
||||
...props
|
||||
}) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 16 16"
|
||||
fill="currentColor"
|
||||
width={size}
|
||||
height={size}
|
||||
className={className}
|
||||
aria-hidden="true"
|
||||
{...props}
|
||||
>
|
||||
<path d="M4.646 4.646a.5.5 0 0 1 .708 0L8 7.293l2.646-2.647a.5.5 0 0 1 .708.708L8.707 8l2.647 2.646a.5.5 0 0 1-.708.708L8 8.707l-2.646 2.647a.5.5 0 0 1-.708-.708L7.293 8 4.646 5.354a.5.5 0 0 1 0-.708Z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
/**
|
||||
* Search/magnifying glass icon (20x20)
|
||||
* Used for search input
|
||||
*/
|
||||
export const SearchIcon: React.FC<IconProps> = ({
|
||||
size = 20,
|
||||
className,
|
||||
...props
|
||||
}) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
width={size}
|
||||
height={size}
|
||||
className={className}
|
||||
aria-hidden="true"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M9 3.5a5.5 5.5 0 1 0 0 11 5.5 5.5 0 0 0 0-11ZM2 9a7 7 0 1 1 12.452 4.391l3.328 3.329a.75.75 0 1 1-1.06 1.06l-3.329-3.328A7 7 0 0 1 2 9Z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
/**
|
||||
* Refresh/reload icon (16x16)
|
||||
* Used for refresh session list
|
||||
*/
|
||||
export const RefreshIcon: React.FC<IconProps> = ({
|
||||
size = 16,
|
||||
className,
|
||||
...props
|
||||
}) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
width={size}
|
||||
height={size}
|
||||
className={className}
|
||||
aria-hidden="true"
|
||||
{...props}
|
||||
>
|
||||
<path d="M13.3333 8C13.3333 10.9455 10.9455 13.3333 8 13.3333C5.05451 13.3333 2.66663 10.9455 2.66663 8C2.66663 5.05451 5.05451 2.66663 8 2.66663" />
|
||||
<path d="M10.6666 8L13.3333 8M13.3333 8L13.3333 5.33333M13.3333 8L10.6666 10.6667" />
|
||||
</svg>
|
||||
);
|
||||
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*
|
||||
* Special UI icons
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import type { IconProps } from './types.js';
|
||||
|
||||
interface ThinkingIconProps extends IconProps {
|
||||
/**
|
||||
* Whether thinking is enabled (affects styling)
|
||||
*/
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
export const ThinkingIcon: React.FC<ThinkingIconProps> = ({
|
||||
size = 16,
|
||||
className,
|
||||
enabled = false,
|
||||
style,
|
||||
...props
|
||||
}) => (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className}
|
||||
aria-hidden="true"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
d="M8.00293 1.11523L8.35059 1.12402H8.35352C11.9915 1.30834 14.8848 4.31624 14.8848 8C14.8848 11.8025 11.8025 14.8848 8 14.8848C4.19752 14.8848 1.11523 11.8025 1.11523 8C1.11523 7.67691 1.37711 7.41504 1.7002 7.41504C2.02319 7.41514 2.28516 7.67698 2.28516 8C2.28516 11.1563 4.84369 13.7148 8 13.7148C11.1563 13.7148 13.7148 11.1563 13.7148 8C13.7148 4.94263 11.3141 2.4464 8.29492 2.29297V2.29199L7.99609 2.28516H7.9873V2.28418L7.89648 2.27539L7.88281 2.27441V2.27344C7.61596 2.21897 7.41513 1.98293 7.41504 1.7002C7.41504 1.37711 7.67691 1.11523 8 1.11523H8.00293ZM8 3.81543C8.32309 3.81543 8.58496 4.0773 8.58496 4.40039V7.6377L10.9619 8.82715C11.2505 8.97169 11.3678 9.32256 11.2236 9.61133C11.0972 9.86425 10.8117 9.98544 10.5488 9.91504L10.5352 9.91211V9.91016L10.4502 9.87891L10.4385 9.87402V9.87305L7.73828 8.52344C7.54007 8.42433 7.41504 8.22155 7.41504 8V4.40039C7.41504 4.0773 7.67691 3.81543 8 3.81543ZM2.44336 5.12695C2.77573 5.19517 3.02597 5.48929 3.02637 5.8418C3.02637 6.19456 2.7761 6.49022 2.44336 6.55859L2.2959 6.57324C1.89241 6.57324 1.56543 6.24529 1.56543 5.8418C1.56588 5.43853 1.89284 5.1123 2.2959 5.1123L2.44336 5.12695ZM3.46094 2.72949C3.86418 2.72984 4.19017 3.05712 4.19043 3.45996V3.46094C4.19009 3.86393 3.86392 4.19008 3.46094 4.19043H3.45996C3.05712 4.19017 2.72983 3.86419 2.72949 3.46094V3.45996C2.72976 3.05686 3.05686 2.72976 3.45996 2.72949H3.46094ZM5.98926 1.58008C6.32235 1.64818 6.57324 1.94276 6.57324 2.2959L6.55859 2.44336C6.49022 2.7761 6.19456 3.02637 5.8418 3.02637C5.43884 3.02591 5.11251 2.69895 5.1123 2.2959L5.12695 2.14844C5.19504 1.81591 5.48906 1.56583 5.8418 1.56543L5.98926 1.58008Z"
|
||||
strokeWidth="0.27"
|
||||
style={{
|
||||
stroke: enabled
|
||||
? 'var(--app-qwen-ivory)'
|
||||
: 'var(--app-secondary-foreground)',
|
||||
fill: enabled
|
||||
? 'var(--app-qwen-ivory)'
|
||||
: 'var(--app-secondary-foreground)',
|
||||
...style,
|
||||
}}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const TerminalIcon: React.FC<IconProps> = ({
|
||||
size = 20,
|
||||
className,
|
||||
...props
|
||||
}) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
width={size}
|
||||
height={size}
|
||||
className={className}
|
||||
aria-hidden="true"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M5.14648 7.14648C5.34175 6.95122 5.65825 6.95122 5.85352 7.14648L8.35352 9.64648C8.44728 9.74025 8.5 9.86739 8.5 10C8.5 10.0994 8.47037 10.1958 8.41602 10.2773L8.35352 10.3535L5.85352 12.8535C5.65825 13.0488 5.34175 13.0488 5.14648 12.8535C4.95122 12.6583 4.95122 12.3417 5.14648 12.1465L7.29297 10L5.14648 7.85352C4.95122 7.65825 4.95122 7.34175 5.14648 7.14648Z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
<path d="M14.5 12C14.7761 12 15 12.2239 15 12.5C15 12.7761 14.7761 13 14.5 13H9.5C9.22386 13 9 12.7761 9 12.5C9 12.2239 9.22386 12 9.5 12H14.5Z" />
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M16.5 4C17.3284 4 18 4.67157 18 5.5V14.5C18 15.3284 17.3284 16 16.5 16H3.5C2.67157 16 2 15.3284 2 14.5V5.5C2 4.67157 2.67157 4 3.5 4H16.5ZM3.5 5C3.22386 5 3 5.22386 3 5.5V14.5C3 14.7761 3.22386 15 3.5 15H16.5C16.7761 15 17 14.7761 17 14.5V5.5C17 5.22386 16.7761 5 16.5 5H3.5Z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
@@ -0,0 +1,188 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*
|
||||
* Status and state related icons
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import type { IconProps } from './types.js';
|
||||
|
||||
/**
|
||||
* Plan completed icon (14x14)
|
||||
* Used for completed plan items
|
||||
*/
|
||||
export const PlanCompletedIcon: React.FC<IconProps> = ({
|
||||
size = 14,
|
||||
className,
|
||||
...props
|
||||
}) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 14 14"
|
||||
fill="none"
|
||||
width={size}
|
||||
height={size}
|
||||
className={className}
|
||||
aria-hidden="true"
|
||||
{...props}
|
||||
>
|
||||
<circle cx="7" cy="7" r="6" fill="currentColor" opacity="0.2" />
|
||||
<path
|
||||
d="M4 7.5L6 9.5L10 4.5"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
/**
|
||||
* Plan in progress icon (14x14)
|
||||
* Used for in-progress plan items
|
||||
*/
|
||||
export const PlanInProgressIcon: React.FC<IconProps> = ({
|
||||
size = 14,
|
||||
className,
|
||||
...props
|
||||
}) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 14 14"
|
||||
fill="none"
|
||||
width={size}
|
||||
height={size}
|
||||
className={className}
|
||||
aria-hidden="true"
|
||||
{...props}
|
||||
>
|
||||
<circle
|
||||
cx="7"
|
||||
cy="7"
|
||||
r="5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2.5"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
/**
|
||||
* Plan pending icon (14x14)
|
||||
* Used for pending plan items
|
||||
*/
|
||||
export const PlanPendingIcon: React.FC<IconProps> = ({
|
||||
size = 14,
|
||||
className,
|
||||
...props
|
||||
}) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 14 14"
|
||||
fill="none"
|
||||
width={size}
|
||||
height={size}
|
||||
className={className}
|
||||
aria-hidden="true"
|
||||
{...props}
|
||||
>
|
||||
<circle
|
||||
cx="7"
|
||||
cy="7"
|
||||
r="5.5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
/**
|
||||
* Warning triangle icon (20x20)
|
||||
* Used for warning messages
|
||||
*/
|
||||
export const WarningTriangleIcon: React.FC<IconProps> = ({
|
||||
size = 20,
|
||||
className,
|
||||
...props
|
||||
}) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
width={size}
|
||||
height={size}
|
||||
className={className}
|
||||
aria-hidden="true"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M8.485 2.495c.673-1.167 2.357-1.167 3.03 0l6.28 10.875c.673 1.167-.17 2.625-1.516 2.625H3.72c-1.347 0-2.189-1.458-1.515-2.625L8.485 2.495ZM10 5a.75.75 0 0 1 .75.75v3.5a.75.75 0 0 1-1.5 0v-3.5A.75.75 0 0 1 10 5Zm0 9a1 1 0 1 0 0-2 1 1 0 0 0 0 2Z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
/**
|
||||
* User profile icon (16x16)
|
||||
* Used for login command
|
||||
*/
|
||||
export const UserIcon: React.FC<IconProps> = ({
|
||||
size = 16,
|
||||
className,
|
||||
...props
|
||||
}) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 16 16"
|
||||
fill="currentColor"
|
||||
width={size}
|
||||
height={size}
|
||||
className={className}
|
||||
aria-hidden="true"
|
||||
{...props}
|
||||
>
|
||||
<path d="M8 8a3 3 0 1 0 0-6 3 3 0 0 0 0 6ZM12.735 14c.618 0 1.093-.561.872-1.139a6.002 6.002 0 0 0-11.215 0c-.22.578.254 1.139.872 1.139h9.47Z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const SymbolIcon: React.FC<IconProps> = ({
|
||||
size = 16,
|
||||
className,
|
||||
...props
|
||||
}) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 16 16"
|
||||
fill="currentColor"
|
||||
width={size}
|
||||
height={size}
|
||||
className={className}
|
||||
aria-hidden="true"
|
||||
{...props}
|
||||
>
|
||||
<path d="M8 1a.5.5 0 0 1 .5.5v5.793l2.146-2.147a.5.5 0 0 1 .708.708l-3 3a.5.5 0 0 1-.708 0l-3-3a.5.5 0 1 1 .708-.708L7.5 7.293V1.5A.5.5 0 0 1 8 1Z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const SelectionIcon: React.FC<IconProps> = ({
|
||||
size = 16,
|
||||
className,
|
||||
...props
|
||||
}) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 16 16"
|
||||
fill="currentColor"
|
||||
width={size}
|
||||
height={size}
|
||||
className={className}
|
||||
aria-hidden="true"
|
||||
{...props}
|
||||
>
|
||||
<path d="M2 3.5a.5.5 0 0 1 .5-.5h11a.5.5 0 0 1 0 1h-11a.5.5 0 0 1-.5-.5Zm0 4a.5.5 0 0 1 .5-.5h7a.5.5 0 0 1 0 1h-7a.5.5 0 0 1-.5-.5Zm0 4a.5.5 0 0 1 .5-.5h9a.5.5 0 0 1 0 1h-9a.5.5 0 0 1-.5-.5Z" />
|
||||
</svg>
|
||||
);
|
||||
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*
|
||||
* Stop icon for canceling operations
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import type { IconProps } from './types.js';
|
||||
|
||||
/**
|
||||
* Stop/square icon (16x16)
|
||||
* Used for stop/cancel operations
|
||||
*/
|
||||
export const StopIcon: React.FC<IconProps> = ({
|
||||
size = 16,
|
||||
className,
|
||||
...props
|
||||
}) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 16 16"
|
||||
fill="currentColor"
|
||||
width={size}
|
||||
height={size}
|
||||
className={className}
|
||||
aria-hidden="true"
|
||||
{...props}
|
||||
>
|
||||
<rect x="4" y="4" width="8" height="8" rx="1" />
|
||||
</svg>
|
||||
);
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
export type { IconProps } from './types.js';
|
||||
export { FileIcon, FileListIcon, FolderIcon } from './FileIcons.js';
|
||||
|
||||
// Navigation icons
|
||||
export {
|
||||
ChevronDownIcon,
|
||||
PlusIcon,
|
||||
PlusSmallIcon,
|
||||
ArrowUpIcon,
|
||||
CloseIcon,
|
||||
CloseSmallIcon,
|
||||
SearchIcon,
|
||||
RefreshIcon,
|
||||
} from './NavigationIcons.js';
|
||||
|
||||
// Edit mode icons
|
||||
export {
|
||||
EditPencilIcon,
|
||||
AutoEditIcon,
|
||||
PlanModeIcon,
|
||||
CodeBracketsIcon,
|
||||
HideContextIcon,
|
||||
SlashCommandIcon,
|
||||
LinkIcon,
|
||||
OpenDiffIcon,
|
||||
} from './EditIcons.js';
|
||||
|
||||
// Status icons
|
||||
export {
|
||||
PlanCompletedIcon,
|
||||
PlanInProgressIcon,
|
||||
PlanPendingIcon,
|
||||
WarningTriangleIcon,
|
||||
UserIcon,
|
||||
SymbolIcon,
|
||||
SelectionIcon,
|
||||
} from './StatusIcons.js';
|
||||
|
||||
// Special icons
|
||||
export { ThinkingIcon, TerminalIcon } from './SpecialIcons.js';
|
||||
|
||||
// Stop icon
|
||||
export { StopIcon } from './StopIcon.js';
|
||||
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*
|
||||
* Common icon props interface
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
|
||||
export interface IconProps extends React.SVGProps<SVGSVGElement> {
|
||||
/**
|
||||
* Icon size (width and height)
|
||||
* @default 16
|
||||
*/
|
||||
size?: number;
|
||||
|
||||
/**
|
||||
* Additional CSS classes
|
||||
*/
|
||||
className?: string;
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { ChevronDownIcon, PlusIcon } from '../icons/index.js';
|
||||
|
||||
interface ChatHeaderProps {
|
||||
currentSessionTitle: string;
|
||||
onLoadSessions: () => void;
|
||||
onNewSession: () => void;
|
||||
}
|
||||
|
||||
export const ChatHeader: React.FC<ChatHeaderProps> = ({
|
||||
currentSessionTitle,
|
||||
onLoadSessions,
|
||||
onNewSession,
|
||||
}) => (
|
||||
<div
|
||||
className="chat-header flex items-center select-none w-full border-b border-[var(--app-primary-border-color)] bg-[var(--app-header-background)] py-1.5 px-2.5"
|
||||
style={{ borderBottom: '1px solid var(--app-primary-border-color)' }}
|
||||
>
|
||||
<button
|
||||
className="flex items-center gap-1.5 py-0.5 px-2 bg-transparent border-none rounded cursor-pointer outline-none min-w-0 max-w-[300px] overflow-hidden text-[var(--vscode-chat-font-size,13px)] font-[var(--vscode-chat-font-family)] hover:bg-[var(--app-ghost-button-hover-background)] focus:bg-[var(--app-ghost-button-hover-background)]"
|
||||
onClick={onLoadSessions}
|
||||
title="Past conversations"
|
||||
>
|
||||
<span className="whitespace-nowrap overflow-hidden text-ellipsis min-w-0 font-medium">
|
||||
{currentSessionTitle}
|
||||
</span>
|
||||
<ChevronDownIcon className="w-4 h-4 flex-shrink-0" />
|
||||
</button>
|
||||
|
||||
<div className="flex-1 min-w-1"></div>
|
||||
|
||||
<button
|
||||
className="flex items-center justify-center p-1 bg-transparent border-none rounded cursor-pointer outline-none hover:bg-[var(--app-ghost-button-hover-background)]"
|
||||
onClick={onNewSession}
|
||||
title="New Session"
|
||||
style={{ padding: '4px' }}
|
||||
>
|
||||
<PlusIcon className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
@@ -0,0 +1,172 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import type { CompletionItem } from '../../../types/completionItemTypes.js';
|
||||
|
||||
interface CompletionMenuProps {
|
||||
items: CompletionItem[];
|
||||
onSelect: (item: CompletionItem) => void;
|
||||
onClose: () => void;
|
||||
title?: string;
|
||||
selectedIndex?: number;
|
||||
}
|
||||
|
||||
export const CompletionMenu: React.FC<CompletionMenuProps> = ({
|
||||
items,
|
||||
onSelect,
|
||||
onClose,
|
||||
title,
|
||||
selectedIndex = 0,
|
||||
}) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [selected, setSelected] = useState(selectedIndex);
|
||||
// Mount state to drive a simple Tailwind transition (replaces CSS keyframes)
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
useEffect(() => setSelected(selectedIndex), [selectedIndex]);
|
||||
useEffect(() => setMounted(true), []);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (
|
||||
containerRef.current &&
|
||||
!containerRef.current.contains(event.target as Node)
|
||||
) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
switch (event.key) {
|
||||
case 'ArrowDown':
|
||||
event.preventDefault();
|
||||
setSelected((prev) => Math.min(prev + 1, items.length - 1));
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
event.preventDefault();
|
||||
setSelected((prev) => Math.max(prev - 1, 0));
|
||||
break;
|
||||
case 'Enter':
|
||||
event.preventDefault();
|
||||
if (items[selected]) {
|
||||
onSelect(items[selected]);
|
||||
}
|
||||
break;
|
||||
case 'Escape':
|
||||
event.preventDefault();
|
||||
onClose();
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, [items, selected, onSelect, onClose]);
|
||||
|
||||
useEffect(() => {
|
||||
const selectedEl = containerRef.current?.querySelector(
|
||||
`[data-index="${selected}"]`,
|
||||
);
|
||||
if (selectedEl) {
|
||||
selectedEl.scrollIntoView({ block: 'nearest' });
|
||||
}
|
||||
}, [selected]);
|
||||
|
||||
if (!items.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
role="menu"
|
||||
className={[
|
||||
// Semantic class name for readability (no CSS attached)
|
||||
'completion-menu',
|
||||
// Positioning and container styling (Tailwind)
|
||||
'absolute bottom-full left-0 right-0 mb-2 flex flex-col overflow-hidden',
|
||||
'rounded-large border bg-[var(--app-menu-background)]',
|
||||
'border-[var(--app-input-border)] max-h-[50vh] z-[1000]',
|
||||
// Mount animation (fade + slight slide up) via keyframes
|
||||
mounted ? 'animate-completion-menu-enter' : '',
|
||||
].join(' ')}
|
||||
>
|
||||
{/* Optional top spacer for visual separation from the input */}
|
||||
<div className="h-1" />
|
||||
<div
|
||||
className={[
|
||||
// Semantic
|
||||
'completion-menu-list',
|
||||
// Scroll area
|
||||
'flex max-h-[300px] flex-col overflow-y-auto',
|
||||
// Spacing driven by theme vars
|
||||
'p-[var(--app-list-padding)] pb-2 gap-[var(--app-list-gap)]',
|
||||
].join(' ')}
|
||||
>
|
||||
{title && (
|
||||
<div className="completion-menu-section-label px-3 py-1 text-[var(--app-primary-foreground)] opacity-50 text-[0.9em]">
|
||||
{title}
|
||||
</div>
|
||||
)}
|
||||
{items.map((item, index) => {
|
||||
const isActive = index === selected;
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
data-index={index}
|
||||
role="menuitem"
|
||||
onClick={() => onSelect(item)}
|
||||
onMouseEnter={() => setSelected(index)}
|
||||
className={[
|
||||
// Semantic
|
||||
'completion-menu-item',
|
||||
// Hit area
|
||||
'mx-1 cursor-pointer rounded-[var(--app-list-border-radius)]',
|
||||
'p-[var(--app-list-item-padding)]',
|
||||
// Active background
|
||||
isActive ? 'bg-[var(--app-list-active-background)]' : '',
|
||||
].join(' ')}
|
||||
>
|
||||
<div className="completion-menu-item-row flex items-center justify-between gap-2">
|
||||
{item.icon && (
|
||||
<span className="completion-menu-item-icon inline-flex h-4 w-4 items-center justify-center text-[var(--vscode-symbolIcon-fileForeground,#cccccc)]">
|
||||
{item.icon}
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
className={[
|
||||
'completion-menu-item-label flex-1 truncate',
|
||||
isActive
|
||||
? 'text-[var(--app-list-active-foreground)]'
|
||||
: 'text-[var(--app-primary-foreground)]',
|
||||
].join(' ')}
|
||||
>
|
||||
{item.label}
|
||||
</span>
|
||||
{item.description && (
|
||||
<span
|
||||
className="completion-menu-item-desc max-w-[50%] truncate text-[0.9em] text-[var(--app-secondary-foreground)] opacity-70"
|
||||
title={item.description}
|
||||
>
|
||||
{item.description}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { generateIconUrl } from '../../utils/resourceUrl.js';
|
||||
|
||||
export const EmptyState: React.FC = () => {
|
||||
// Generate icon URL using the utility function
|
||||
const iconUri = generateIconUrl('icon.png');
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full p-5 md:p-10">
|
||||
<div className="flex flex-col items-center gap-8 w-full">
|
||||
{/* Qwen Logo */}
|
||||
<div className="flex flex-col items-center gap-6">
|
||||
<img
|
||||
src={iconUri}
|
||||
alt="Qwen Logo"
|
||||
className="w-[60px] h-[60px] object-contain"
|
||||
/>
|
||||
<div className="text-center">
|
||||
<div className="text-[15px] text-app-primary-foreground leading-normal font-normal max-w-[400px]">
|
||||
What to do first? Ask about this codebase or we can start writing
|
||||
code.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*
|
||||
* FileLink component - Clickable file path links
|
||||
* Supports clicking to open files and jump to specified line and column numbers
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { useVSCode } from '../../hooks/useVSCode.js';
|
||||
// Tailwind rewrite: styles from FileLink.css are now expressed as utility classes
|
||||
|
||||
/**
|
||||
* Props for FileLink
|
||||
*/
|
||||
interface FileLinkProps {
|
||||
/** File path */
|
||||
path: string;
|
||||
/** Optional line number (starting from 1) */
|
||||
line?: number | null;
|
||||
/** Optional column number (starting from 1) */
|
||||
column?: number | null;
|
||||
/** Whether to show full path, default false (show filename only) */
|
||||
showFullPath?: boolean;
|
||||
/** Optional custom class name */
|
||||
className?: string;
|
||||
/** Whether to disable click behavior (use when parent element handles clicks) */
|
||||
disableClick?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract filename from full path
|
||||
* @param path File path
|
||||
* @returns Filename
|
||||
*/
|
||||
function getFileName(path: string): string {
|
||||
const segments = path.split(/[/\\]/);
|
||||
return segments[segments.length - 1] || path;
|
||||
}
|
||||
|
||||
/**
|
||||
* FileLink component - Clickable file link
|
||||
*
|
||||
* Features:
|
||||
* - Click to open file
|
||||
* - Support line and column number navigation
|
||||
* - Hover to show full path
|
||||
* - Optional display mode (full path vs filename only)
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <FileLink path="/src/App.tsx" line={42} />
|
||||
* <FileLink path="/src/components/Button.tsx" line={10} column={5} showFullPath={true} />
|
||||
* ```
|
||||
*/
|
||||
export const FileLink: React.FC<FileLinkProps> = ({
|
||||
path,
|
||||
line,
|
||||
column,
|
||||
showFullPath = false,
|
||||
className = '',
|
||||
disableClick = false,
|
||||
}) => {
|
||||
const vscode = useVSCode();
|
||||
|
||||
/**
|
||||
* Handle click event - Send message to VSCode to open file
|
||||
*/
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
// Always prevent default behavior (prevent <a> tag # navigation)
|
||||
e.preventDefault();
|
||||
|
||||
if (disableClick) {
|
||||
// If click is disabled, return directly without stopping propagation
|
||||
// This allows parent elements to handle click events
|
||||
return;
|
||||
}
|
||||
|
||||
// If click is enabled, stop event propagation
|
||||
e.stopPropagation();
|
||||
|
||||
// Build full path including line and column numbers
|
||||
let fullPath = path;
|
||||
if (line !== null && line !== undefined) {
|
||||
fullPath += `:${line}`;
|
||||
if (column !== null && column !== undefined) {
|
||||
fullPath += `:${column}`;
|
||||
}
|
||||
}
|
||||
|
||||
console.log('[FileLink] Opening file:', fullPath);
|
||||
|
||||
vscode.postMessage({
|
||||
type: 'openFile',
|
||||
data: { path: fullPath },
|
||||
});
|
||||
};
|
||||
|
||||
// Build display text
|
||||
const displayPath = showFullPath ? path : getFileName(path);
|
||||
|
||||
// Build hover tooltip (always show full path)
|
||||
const fullDisplayText =
|
||||
line !== null && line !== undefined
|
||||
? column !== null && column !== undefined
|
||||
? `${path}:${line}:${column}`
|
||||
: `${path}:${line}`
|
||||
: path;
|
||||
|
||||
return (
|
||||
<a
|
||||
href="#"
|
||||
className={[
|
||||
'file-link',
|
||||
// Layout + interaction
|
||||
// Use items-center + leading-none to vertically center within surrounding rows
|
||||
'inline-flex items-center leading-none',
|
||||
disableClick
|
||||
? 'pointer-events-none cursor-[inherit] hover:no-underline'
|
||||
: 'cursor-pointer',
|
||||
// Typography + color: match theme body text and fixed size
|
||||
'text-[11px] no-underline hover:underline',
|
||||
'text-[var(--app-primary-foreground)]',
|
||||
// Transitions
|
||||
'transition-colors duration-100 ease-in-out',
|
||||
// Focus ring (keyboard nav)
|
||||
'focus:outline focus:outline-1 focus:outline-[var(--vscode-focusBorder)] focus:outline-offset-2 focus:rounded-[2px]',
|
||||
// Active state
|
||||
'active:opacity-80',
|
||||
className,
|
||||
].join(' ')}
|
||||
onClick={handleClick}
|
||||
title={fullDisplayText}
|
||||
role="button"
|
||||
aria-label={`Open file: ${fullDisplayText}`}
|
||||
// Inherit font family from context so it matches theme body text.
|
||||
>
|
||||
<span className="file-link-path">{displayPath}</span>
|
||||
{line !== null && line !== undefined && (
|
||||
<span className="file-link-location opacity-70 text-[0.9em] font-normal dark:opacity-60">
|
||||
:{line}
|
||||
{column !== null && column !== undefined && <>:{column}</>}
|
||||
</span>
|
||||
)}
|
||||
</a>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,293 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import {
|
||||
EditPencilIcon,
|
||||
AutoEditIcon,
|
||||
PlanModeIcon,
|
||||
CodeBracketsIcon,
|
||||
HideContextIcon,
|
||||
ThinkingIcon,
|
||||
SlashCommandIcon,
|
||||
LinkIcon,
|
||||
ArrowUpIcon,
|
||||
StopIcon,
|
||||
} from '../icons/index.js';
|
||||
import { CompletionMenu } from '../layout/CompletionMenu.js';
|
||||
import type { CompletionItem } from '../../../types/completionItemTypes.js';
|
||||
import { getApprovalModeInfoFromString } from '../../../types/acpTypes.js';
|
||||
import type { ApprovalModeValue } from '../../../types/acpTypes.js';
|
||||
|
||||
interface InputFormProps {
|
||||
inputText: string;
|
||||
// Note: RefObject<T> carries nullability in its `current` property, so the
|
||||
// generic should be `HTMLDivElement` (not `HTMLDivElement | null`).
|
||||
inputFieldRef: React.RefObject<HTMLDivElement>;
|
||||
isStreaming: boolean;
|
||||
isWaitingForResponse: boolean;
|
||||
isComposing: boolean;
|
||||
editMode: ApprovalModeValue;
|
||||
thinkingEnabled: boolean;
|
||||
activeFileName: string | null;
|
||||
activeSelection: { startLine: number; endLine: number } | null;
|
||||
// Whether to auto-load the active editor selection/path into context
|
||||
skipAutoActiveContext: boolean;
|
||||
onInputChange: (text: string) => void;
|
||||
onCompositionStart: () => void;
|
||||
onCompositionEnd: () => void;
|
||||
onKeyDown: (e: React.KeyboardEvent) => void;
|
||||
onSubmit: (e: React.FormEvent) => void;
|
||||
onCancel: () => void;
|
||||
onToggleEditMode: () => void;
|
||||
onToggleThinking: () => void;
|
||||
onFocusActiveEditor: () => void;
|
||||
onToggleSkipAutoActiveContext: () => void;
|
||||
onShowCommandMenu: () => void;
|
||||
onAttachContext: () => void;
|
||||
completionIsOpen: boolean;
|
||||
completionItems?: CompletionItem[];
|
||||
onCompletionSelect?: (item: CompletionItem) => void;
|
||||
onCompletionClose?: () => void;
|
||||
}
|
||||
|
||||
// Get edit mode display info using helper function
|
||||
const getEditModeInfo = (editMode: ApprovalModeValue) => {
|
||||
const info = getApprovalModeInfoFromString(editMode);
|
||||
|
||||
// Map icon types to actual icons
|
||||
let icon = null;
|
||||
switch (info.iconType) {
|
||||
case 'edit':
|
||||
icon = <EditPencilIcon />;
|
||||
break;
|
||||
case 'auto':
|
||||
icon = <AutoEditIcon />;
|
||||
break;
|
||||
case 'plan':
|
||||
icon = <PlanModeIcon />;
|
||||
break;
|
||||
case 'yolo':
|
||||
icon = <AutoEditIcon />;
|
||||
break;
|
||||
default:
|
||||
icon = null;
|
||||
break;
|
||||
}
|
||||
|
||||
return {
|
||||
text: info.label,
|
||||
title: info.title,
|
||||
icon,
|
||||
};
|
||||
};
|
||||
|
||||
export const InputForm: React.FC<InputFormProps> = ({
|
||||
inputText,
|
||||
inputFieldRef,
|
||||
isStreaming,
|
||||
isWaitingForResponse,
|
||||
isComposing,
|
||||
editMode,
|
||||
thinkingEnabled,
|
||||
activeFileName,
|
||||
activeSelection,
|
||||
skipAutoActiveContext,
|
||||
onInputChange,
|
||||
onCompositionStart,
|
||||
onCompositionEnd,
|
||||
onKeyDown,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
onToggleEditMode,
|
||||
onToggleThinking,
|
||||
onToggleSkipAutoActiveContext,
|
||||
onShowCommandMenu,
|
||||
onAttachContext,
|
||||
completionIsOpen,
|
||||
completionItems,
|
||||
onCompletionSelect,
|
||||
onCompletionClose,
|
||||
}) => {
|
||||
const editModeInfo = getEditModeInfo(editMode);
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
// ESC should cancel the current interaction (stop generation)
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
onCancel();
|
||||
return;
|
||||
}
|
||||
// If composing (Chinese IME input), don't process Enter key
|
||||
if (e.key === 'Enter' && !e.shiftKey && !isComposing) {
|
||||
// If CompletionMenu is open, let it handle Enter key
|
||||
if (completionIsOpen) {
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
onSubmit(e);
|
||||
}
|
||||
onKeyDown(e);
|
||||
};
|
||||
|
||||
// Selection label like "6 lines selected"; no line numbers
|
||||
const selectedLinesCount = activeSelection
|
||||
? Math.max(1, activeSelection.endLine - activeSelection.startLine + 1)
|
||||
: 0;
|
||||
const selectedLinesText =
|
||||
selectedLinesCount > 0
|
||||
? `${selectedLinesCount} ${selectedLinesCount === 1 ? 'line' : 'lines'} selected`
|
||||
: '';
|
||||
|
||||
return (
|
||||
<div
|
||||
className="p-1 px-4 pb-4"
|
||||
style={{ backgroundColor: 'var(--app-primary-background)' }}
|
||||
>
|
||||
<div className="block">
|
||||
<form className="composer-form" onSubmit={onSubmit}>
|
||||
{/* Inner background layer */}
|
||||
<div className="composer-overlay" />
|
||||
|
||||
{/* Banner area */}
|
||||
<div className="input-banner" />
|
||||
|
||||
<div className="relative flex z-[1]">
|
||||
{completionIsOpen &&
|
||||
completionItems &&
|
||||
completionItems.length > 0 &&
|
||||
onCompletionSelect &&
|
||||
onCompletionClose && (
|
||||
<CompletionMenu
|
||||
items={completionItems}
|
||||
onSelect={onCompletionSelect}
|
||||
onClose={onCompletionClose}
|
||||
title={undefined}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div
|
||||
ref={inputFieldRef}
|
||||
contentEditable="plaintext-only"
|
||||
className="composer-input"
|
||||
role="textbox"
|
||||
aria-label="Message input"
|
||||
aria-multiline="true"
|
||||
data-placeholder="Ask Qwen Code …"
|
||||
// Use a data flag so CSS can show placeholder even if the browser
|
||||
// inserts an invisible <br> into contentEditable (so :empty no longer matches)
|
||||
data-empty={inputText.trim().length === 0 ? 'true' : 'false'}
|
||||
onInput={(e) => {
|
||||
const target = e.target as HTMLDivElement;
|
||||
onInputChange(target.textContent || '');
|
||||
}}
|
||||
onCompositionStart={onCompositionStart}
|
||||
onCompositionEnd={onCompositionEnd}
|
||||
onKeyDown={handleKeyDown}
|
||||
suppressContentEditableWarning
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="composer-actions">
|
||||
{/* Edit mode button */}
|
||||
<button
|
||||
type="button"
|
||||
className="btn-text-compact btn-text-compact--primary"
|
||||
title={editModeInfo.title}
|
||||
onClick={onToggleEditMode}
|
||||
>
|
||||
{editModeInfo.icon}
|
||||
{/* Let the label truncate with ellipsis; hide on very small screens */}
|
||||
<span className="hidden sm:inline">{editModeInfo.text}</span>
|
||||
</button>
|
||||
|
||||
{/* Active file indicator */}
|
||||
{activeFileName && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn-text-compact btn-text-compact--primary"
|
||||
title={(() => {
|
||||
if (skipAutoActiveContext) {
|
||||
return selectedLinesText
|
||||
? `Active selection will NOT be auto-loaded into context: ${selectedLinesText}`
|
||||
: `Active file will NOT be auto-loaded into context: ${activeFileName}`;
|
||||
}
|
||||
return selectedLinesText
|
||||
? `Showing Qwen Code your current selection: ${selectedLinesText}`
|
||||
: `Showing Qwen Code your current file: ${activeFileName}`;
|
||||
})()}
|
||||
onClick={onToggleSkipAutoActiveContext}
|
||||
>
|
||||
{skipAutoActiveContext ? (
|
||||
<HideContextIcon />
|
||||
) : (
|
||||
<CodeBracketsIcon />
|
||||
)}
|
||||
{/* Truncate file path/selection; hide label on very small screens */}
|
||||
<span className="hidden sm:inline">
|
||||
{selectedLinesText || activeFileName}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Spacer */}
|
||||
<div className="flex-1 min-w-0" />
|
||||
|
||||
{/* Thinking button */}
|
||||
<button
|
||||
type="button"
|
||||
className={`btn-icon-compact ${thinkingEnabled ? 'btn-icon-compact--active' : ''}`}
|
||||
title={thinkingEnabled ? 'Thinking on' : 'Thinking off'}
|
||||
onClick={onToggleThinking}
|
||||
>
|
||||
<ThinkingIcon enabled={thinkingEnabled} />
|
||||
</button>
|
||||
|
||||
{/* Command button */}
|
||||
<button
|
||||
type="button"
|
||||
className="btn-icon-compact hover:text-[var(--app-primary-foreground)]"
|
||||
title="Show command menu (/)"
|
||||
onClick={onShowCommandMenu}
|
||||
>
|
||||
<SlashCommandIcon />
|
||||
</button>
|
||||
|
||||
{/* Attach button */}
|
||||
<button
|
||||
type="button"
|
||||
className="btn-icon-compact hover:text-[var(--app-primary-foreground)]"
|
||||
title="Attach context (Cmd/Ctrl + /)"
|
||||
onClick={onAttachContext}
|
||||
>
|
||||
<LinkIcon />
|
||||
</button>
|
||||
|
||||
{/* Send/Stop button */}
|
||||
{isStreaming || isWaitingForResponse ? (
|
||||
<button
|
||||
type="button"
|
||||
className="btn-send-compact [&>svg]:w-5 [&>svg]:h-5"
|
||||
onClick={onCancel}
|
||||
title="Stop generation"
|
||||
>
|
||||
<StopIcon />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="submit"
|
||||
className="btn-send-compact [&>svg]:w-5 [&>svg]:h-5"
|
||||
disabled={!inputText.trim()}
|
||||
>
|
||||
<ArrowUpIcon />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,154 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { groupSessionsByDate } from '../../utils/sessionGrouping.js';
|
||||
import { getTimeAgo } from '../../utils/timeUtils.js';
|
||||
import { SearchIcon } from '../icons/index.js';
|
||||
|
||||
interface SessionSelectorProps {
|
||||
visible: boolean;
|
||||
sessions: Array<Record<string, unknown>>;
|
||||
currentSessionId: string | null;
|
||||
searchQuery: string;
|
||||
onSearchChange: (query: string) => void;
|
||||
onSelectSession: (sessionId: string) => void;
|
||||
onClose: () => void;
|
||||
hasMore?: boolean;
|
||||
isLoading?: boolean;
|
||||
onLoadMore?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Session selector component
|
||||
* Display session list and support search and selection
|
||||
*/
|
||||
export const SessionSelector: React.FC<SessionSelectorProps> = ({
|
||||
visible,
|
||||
sessions,
|
||||
currentSessionId,
|
||||
searchQuery,
|
||||
onSearchChange,
|
||||
onSelectSession,
|
||||
onClose,
|
||||
hasMore = false,
|
||||
isLoading = false,
|
||||
onLoadMore,
|
||||
}) => {
|
||||
if (!visible) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const hasNoSessions = sessions.length === 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className="session-selector-backdrop fixed top-0 left-0 right-0 bottom-0 z-[999] bg-transparent"
|
||||
onClick={onClose}
|
||||
/>
|
||||
<div
|
||||
className="session-dropdown fixed bg-[var(--app-menu-background)] rounded-[var(--corner-radius-small)] w-[min(400px,calc(100vw-32px))] max-h-[min(500px,50vh)] flex flex-col shadow-[0_4px_16px_rgba(0,0,0,0.1)] z-[1000] outline-none text-[var(--vscode-chat-font-size,13px)] font-[var(--vscode-chat-font-family)]"
|
||||
tabIndex={-1}
|
||||
style={{
|
||||
top: '30px',
|
||||
left: '10px',
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Search Box */}
|
||||
<div className="session-search p-2 flex items-center gap-2">
|
||||
<SearchIcon className="session-search-icon w-4 h-4 opacity-50 flex-shrink-0 text-[var(--app-primary-foreground)]" />
|
||||
<input
|
||||
type="text"
|
||||
className="session-search-input flex-1 bg-transparent border-none outline-none text-[var(--app-menu-foreground)] text-[var(--vscode-chat-font-size,13px)] font-[var(--vscode-chat-font-family)] p-0 placeholder:text-[var(--app-input-placeholder-foreground)] placeholder:opacity-60"
|
||||
placeholder="Search sessions…"
|
||||
value={searchQuery}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Session List with Grouping */}
|
||||
<div
|
||||
className="session-list-content overflow-y-auto flex-1 select-none p-2"
|
||||
onScroll={(e) => {
|
||||
const el = e.currentTarget;
|
||||
const distanceToBottom =
|
||||
el.scrollHeight - (el.scrollTop + el.clientHeight);
|
||||
if (distanceToBottom < 48 && hasMore && !isLoading) {
|
||||
onLoadMore?.();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{hasNoSessions ? (
|
||||
<div
|
||||
className="p-5 text-center text-[var(--app-secondary-foreground)]"
|
||||
style={{
|
||||
padding: '20px',
|
||||
textAlign: 'center',
|
||||
color: 'var(--app-secondary-foreground)',
|
||||
}}
|
||||
>
|
||||
{searchQuery ? 'No matching sessions' : 'No sessions available'}
|
||||
</div>
|
||||
) : (
|
||||
groupSessionsByDate(sessions).map((group) => (
|
||||
<React.Fragment key={group.label}>
|
||||
<div className="session-group-label p-1 px-2 text-[var(--app-primary-foreground)] opacity-50 text-[0.9em] font-medium [&:not(:first-child)]:mt-2">
|
||||
{group.label}
|
||||
</div>
|
||||
<div className="session-group flex flex-col gap-[2px]">
|
||||
{group.sessions.map((session) => {
|
||||
const sessionId =
|
||||
(session.id as string) ||
|
||||
(session.sessionId as string) ||
|
||||
'';
|
||||
const title =
|
||||
(session.title as string) ||
|
||||
(session.name as string) ||
|
||||
'Untitled';
|
||||
const lastUpdated =
|
||||
(session.lastUpdated as string) ||
|
||||
(session.startTime as string) ||
|
||||
'';
|
||||
const isActive = sessionId === currentSessionId;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={sessionId}
|
||||
className={`session-item flex items-center justify-between py-1.5 px-2 bg-transparent border-none rounded-md cursor-pointer text-left w-full text-[var(--vscode-chat-font-size,13px)] font-[var(--vscode-chat-font-family)] text-[var(--app-primary-foreground)] transition-colors duration-100 hover:bg-[var(--app-list-hover-background)] ${
|
||||
isActive
|
||||
? 'active bg-[var(--app-list-active-background)] text-[var(--app-list-active-foreground)] font-[600]'
|
||||
: ''
|
||||
}`}
|
||||
onClick={() => {
|
||||
onSelectSession(sessionId);
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
<span className="session-item-title flex-1 overflow-hidden text-ellipsis whitespace-nowrap min-w-0">
|
||||
{title}
|
||||
</span>
|
||||
<span className="session-item-time opacity-60 text-[0.9em] flex-shrink-0 ml-3">
|
||||
{getTimeAgo(lastUpdated)}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</React.Fragment>
|
||||
))
|
||||
)}
|
||||
{hasMore && (
|
||||
<div className="p-2 text-center opacity-60 text-[0.9em]">
|
||||
{isLoading ? 'Loading…' : ''}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*
|
||||
* AssistantMessage Component Styles
|
||||
* Pseudo-elements (::before) for bullet points and (::after) for timeline connectors
|
||||
*/
|
||||
|
||||
/* Bullet point indicator using ::before pseudo-element */
|
||||
.assistant-message-container.assistant-message-default::before,
|
||||
.assistant-message-container.assistant-message-success::before,
|
||||
.assistant-message-container.assistant-message-error::before,
|
||||
.assistant-message-container.assistant-message-warning::before,
|
||||
.assistant-message-container.assistant-message-loading::before {
|
||||
content: '\25cf';
|
||||
position: absolute;
|
||||
left: 8px;
|
||||
padding-top: 2px;
|
||||
font-size: 10px;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* Default state - secondary foreground color */
|
||||
.assistant-message-container.assistant-message-default::before {
|
||||
color: var(--app-secondary-foreground);
|
||||
}
|
||||
|
||||
/* Success state - green bullet (maps to .ge) */
|
||||
.assistant-message-container.assistant-message-success::before {
|
||||
color: #74c991;
|
||||
}
|
||||
|
||||
/* Error state - red bullet (maps to .be) */
|
||||
.assistant-message-container.assistant-message-error::before {
|
||||
color: #c74e39;
|
||||
}
|
||||
|
||||
/* Warning state - yellow/orange bullet (maps to .ue) */
|
||||
.assistant-message-container.assistant-message-warning::before {
|
||||
color: #e1c08d;
|
||||
}
|
||||
|
||||
/* Loading state - static bullet (maps to .he) */
|
||||
.assistant-message-container.assistant-message-loading::before {
|
||||
color: var(--app-secondary-foreground);
|
||||
background-color: var(--app-secondary-background);
|
||||
}
|
||||
|
||||
.assistant-message-container.assistant-message-loading::after {
|
||||
display: none
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { MessageContent } from '../MessageContent.js';
|
||||
import './AssistantMessage.css';
|
||||
|
||||
interface AssistantMessageProps {
|
||||
content: string;
|
||||
timestamp: number;
|
||||
onFileClick?: (path: string) => void;
|
||||
status?: 'default' | 'success' | 'error' | 'warning' | 'loading';
|
||||
// When true, render without the left status bullet (no ::before dot)
|
||||
hideStatusIcon?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* AssistantMessage component - renders AI responses with Qwen Code styling
|
||||
* Supports different states: default, success, error, warning, loading
|
||||
*/
|
||||
export const AssistantMessage: React.FC<AssistantMessageProps> = ({
|
||||
content,
|
||||
timestamp: _timestamp,
|
||||
onFileClick,
|
||||
status = 'default',
|
||||
hideStatusIcon = false,
|
||||
}) => {
|
||||
// Empty content not rendered directly, avoid poor visual experience from only showing ::before dot
|
||||
if (!content || content.trim().length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Map status to CSS class (only for ::before pseudo-element)
|
||||
const getStatusClass = () => {
|
||||
if (hideStatusIcon) {
|
||||
return '';
|
||||
}
|
||||
switch (status) {
|
||||
case 'success':
|
||||
return 'assistant-message-success';
|
||||
case 'error':
|
||||
return 'assistant-message-error';
|
||||
case 'warning':
|
||||
return 'assistant-message-warning';
|
||||
case 'loading':
|
||||
return 'assistant-message-loading';
|
||||
default:
|
||||
return 'assistant-message-default';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`qwen-message message-item assistant-message-container ${getStatusClass()}`}
|
||||
style={{
|
||||
width: '100%',
|
||||
alignItems: 'flex-start',
|
||||
paddingLeft: '30px',
|
||||
userSelect: 'text',
|
||||
position: 'relative',
|
||||
// paddingTop: '8px',
|
||||
// paddingBottom: '8px',
|
||||
}}
|
||||
>
|
||||
<span style={{ width: '100%' }}>
|
||||
<div
|
||||
style={{
|
||||
margin: 0,
|
||||
width: '100%',
|
||||
wordWrap: 'break-word',
|
||||
overflowWrap: 'break-word',
|
||||
whiteSpace: 'normal',
|
||||
}}
|
||||
>
|
||||
<MessageContent content={content} onFileClick={onFileClick} />
|
||||
</div>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,223 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*
|
||||
* Styles for MarkdownRenderer component
|
||||
*/
|
||||
|
||||
.markdown-content {
|
||||
/* Base styles for markdown content */
|
||||
line-height: 1.6;
|
||||
color: var(--app-primary-foreground);
|
||||
}
|
||||
|
||||
.markdown-content h1,
|
||||
.markdown-content h2,
|
||||
.markdown-content h3,
|
||||
.markdown-content h4,
|
||||
.markdown-content h5,
|
||||
.markdown-content h6 {
|
||||
margin-top: 1.5em;
|
||||
margin-bottom: 0.5em;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.markdown-content h1 {
|
||||
font-size: 1.75em;
|
||||
border-bottom: 1px solid var(--app-primary-border-color);
|
||||
padding-bottom: 0.3em;
|
||||
}
|
||||
|
||||
.markdown-content h2 {
|
||||
font-size: 1.5em;
|
||||
border-bottom: 1px solid var(--app-primary-border-color);
|
||||
padding-bottom: 0.3em;
|
||||
}
|
||||
|
||||
.markdown-content h3 {
|
||||
font-size: 1.25em;
|
||||
}
|
||||
|
||||
.markdown-content h4 {
|
||||
font-size: 1.1em;
|
||||
}
|
||||
|
||||
.markdown-content h5,
|
||||
.markdown-content h6 {
|
||||
font-size: 1em;
|
||||
}
|
||||
|
||||
.markdown-content p {
|
||||
margin-top: 0;
|
||||
/* margin-bottom: 1em; */
|
||||
}
|
||||
|
||||
.markdown-content ul,
|
||||
.markdown-content ol {
|
||||
margin-top: 1em;
|
||||
margin-bottom: 1em;
|
||||
padding-left: 2em;
|
||||
}
|
||||
|
||||
/* Ensure list markers are visible even with global CSS resets */
|
||||
.markdown-content ul {
|
||||
list-style-type: disc;
|
||||
list-style-position: outside;
|
||||
}
|
||||
|
||||
.markdown-content ol {
|
||||
list-style-type: decimal;
|
||||
list-style-position: outside;
|
||||
}
|
||||
|
||||
/* Nested list styles */
|
||||
.markdown-content ul ul {
|
||||
list-style-type: circle;
|
||||
}
|
||||
|
||||
.markdown-content ul ul ul {
|
||||
list-style-type: square;
|
||||
}
|
||||
|
||||
.markdown-content ol ol {
|
||||
list-style-type: lower-alpha;
|
||||
}
|
||||
|
||||
.markdown-content ol ol ol {
|
||||
list-style-type: lower-roman;
|
||||
}
|
||||
|
||||
/* Style the marker explicitly so themes don't hide it */
|
||||
.markdown-content li::marker {
|
||||
color: var(--app-secondary-foreground);
|
||||
}
|
||||
|
||||
.markdown-content li {
|
||||
margin-bottom: 0.25em;
|
||||
}
|
||||
|
||||
.markdown-content li > p {
|
||||
margin-top: 0.5em;
|
||||
margin-bottom: 0.5em;
|
||||
}
|
||||
|
||||
.markdown-content blockquote {
|
||||
margin: 0 0 1em;
|
||||
padding: 0 1em;
|
||||
border-left: 0.25em solid var(--app-primary-border-color);
|
||||
color: var(--app-secondary-foreground);
|
||||
}
|
||||
|
||||
.markdown-content a {
|
||||
color: var(--app-link-foreground, #007acc);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.markdown-content a:hover {
|
||||
color: var(--app-link-active-foreground, #005a9e);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.markdown-content code {
|
||||
font-family: var(
|
||||
--app-monospace-font-family,
|
||||
'SF Mono',
|
||||
Monaco,
|
||||
'Cascadia Code',
|
||||
'Roboto Mono',
|
||||
Consolas,
|
||||
'Courier New',
|
||||
monospace
|
||||
);
|
||||
font-size: 0.9em;
|
||||
background-color: var(--app-code-background, rgba(0, 0, 0, 0.05));
|
||||
border: 1px solid var(--app-primary-border-color);
|
||||
border-radius: var(--corner-radius-small, 4px);
|
||||
padding: 0.2em 0.4em;
|
||||
white-space: pre-wrap; /* Support automatic line wrapping */
|
||||
word-break: break-word; /* Break words when necessary */
|
||||
}
|
||||
|
||||
.markdown-content pre {
|
||||
margin: 1em 0;
|
||||
padding: 1em;
|
||||
overflow-x: auto;
|
||||
background-color: var(--app-code-background, rgba(0, 0, 0, 0.05));
|
||||
border: 1px solid var(--app-primary-border-color);
|
||||
border-radius: var(--corner-radius-small, 4px);
|
||||
font-family: var(
|
||||
--app-monospace-font-family,
|
||||
'SF Mono',
|
||||
Monaco,
|
||||
'Cascadia Code',
|
||||
'Roboto Mono',
|
||||
Consolas,
|
||||
'Courier New',
|
||||
monospace
|
||||
);
|
||||
font-size: 0.9em;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.markdown-content pre code {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
white-space: pre-wrap; /* Support automatic line wrapping */
|
||||
word-break: break-word; /* Break words when necessary */
|
||||
}
|
||||
|
||||
.markdown-content .file-path-link {
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 0;
|
||||
font-family: var(
|
||||
--app-monospace-font-family,
|
||||
'SF Mono',
|
||||
Monaco,
|
||||
'Cascadia Code',
|
||||
'Roboto Mono',
|
||||
Consolas,
|
||||
'Courier New',
|
||||
monospace
|
||||
);
|
||||
font-size: 0.95em;
|
||||
color: var(--app-link-foreground, #007acc);
|
||||
text-decoration: underline;
|
||||
cursor: pointer;
|
||||
transition: color 0.1s ease;
|
||||
}
|
||||
|
||||
.markdown-content .file-path-link:hover {
|
||||
color: var(--app-link-active-foreground, #005a9e);
|
||||
}
|
||||
|
||||
.markdown-content hr {
|
||||
border: none;
|
||||
border-top: 1px solid var(--app-primary-border-color);
|
||||
margin: 1.5em 0;
|
||||
}
|
||||
|
||||
.markdown-content img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.markdown-content table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 1em 0;
|
||||
}
|
||||
|
||||
.markdown-content th,
|
||||
.markdown-content td {
|
||||
padding: 0.5em 1em;
|
||||
border: 1px solid var(--app-primary-border-color);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.markdown-content th {
|
||||
background-color: var(--app-secondary-background);
|
||||
font-weight: 600;
|
||||
}
|
||||
@@ -0,0 +1,392 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*
|
||||
* MarkdownRenderer component - renders markdown content with syntax highlighting and clickable file paths
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import MarkdownIt from 'markdown-it';
|
||||
import type { Options as MarkdownItOptions } from 'markdown-it';
|
||||
import './MarkdownRenderer.css';
|
||||
|
||||
interface MarkdownRendererProps {
|
||||
content: string;
|
||||
onFileClick?: (filePath: string) => void;
|
||||
/** When false, do not convert file paths into clickable links. Default: true */
|
||||
enableFileLinks?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Regular expressions for parsing content
|
||||
*/
|
||||
// Match absolute file paths like: /path/to/file.ts or C:\path\to\file.ts
|
||||
const FILE_PATH_REGEX =
|
||||
/(?:[a-zA-Z]:)?[/\\](?:[\w\-. ]+[/\\])+[\w\-. ]+\.(tsx?|jsx?|css|scss|json|md|py|java|go|rs|c|cpp|h|hpp|sh|yaml|yml|toml|xml|html|vue|svelte)/gi;
|
||||
// Match file paths with optional line numbers like: /path/to/file.ts#7-14 or C:\path\to\file.ts#7
|
||||
const FILE_PATH_WITH_LINES_REGEX =
|
||||
/(?:[a-zA-Z]:)?[/\\](?:[\w\-. ]+[/\\])+[\w\-. ]+\.(tsx?|jsx?|css|scss|json|md|py|java|go|rs|c|cpp|h|hpp|sh|yaml|yml|toml|xml|html|vue|svelte)#(\d+)(?:-(\d+))?/gi;
|
||||
|
||||
/**
|
||||
* MarkdownRenderer component - renders markdown content with enhanced features
|
||||
*/
|
||||
export const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({
|
||||
content,
|
||||
onFileClick,
|
||||
enableFileLinks = true,
|
||||
}) => {
|
||||
/**
|
||||
* Initialize markdown-it with plugins
|
||||
*/
|
||||
const getMarkdownInstance = (): MarkdownIt => {
|
||||
// Create markdown-it instance with options
|
||||
const md = new MarkdownIt({
|
||||
html: false, // Disable HTML for security
|
||||
xhtmlOut: false,
|
||||
breaks: true,
|
||||
linkify: true,
|
||||
typographer: true,
|
||||
} as MarkdownItOptions);
|
||||
|
||||
return md;
|
||||
};
|
||||
|
||||
/**
|
||||
* Render markdown content to HTML
|
||||
*/
|
||||
const renderMarkdown = (): string => {
|
||||
try {
|
||||
const md = getMarkdownInstance();
|
||||
|
||||
// Process the markdown content
|
||||
let html = md.render(content);
|
||||
|
||||
// Post-process to add file path click handlers unless disabled
|
||||
if (enableFileLinks) {
|
||||
html = processFilePaths(html);
|
||||
}
|
||||
|
||||
return html;
|
||||
} catch (error) {
|
||||
console.error('Error rendering markdown:', error);
|
||||
// Fallback to plain text if markdown rendering fails
|
||||
return escapeHtml(content);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Escape HTML characters for security
|
||||
*/
|
||||
const escapeHtml = (unsafe: string): string =>
|
||||
unsafe
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
|
||||
/**
|
||||
* Process file paths in HTML to make them clickable
|
||||
*/
|
||||
const processFilePaths = (html: string): string => {
|
||||
// If DOM is not available, bail out to avoid breaking SSR
|
||||
if (typeof document === 'undefined') {
|
||||
return html;
|
||||
}
|
||||
|
||||
// Build non-global variants to avoid .test() statefulness
|
||||
const FILE_PATH_NO_G = new RegExp(
|
||||
FILE_PATH_REGEX.source,
|
||||
FILE_PATH_REGEX.flags.replace('g', ''),
|
||||
);
|
||||
const FILE_PATH_WITH_LINES_NO_G = new RegExp(
|
||||
FILE_PATH_WITH_LINES_REGEX.source,
|
||||
FILE_PATH_WITH_LINES_REGEX.flags.replace('g', ''),
|
||||
);
|
||||
// Match a bare file name like README.md (no leading slash)
|
||||
const BARE_FILE_REGEX =
|
||||
/[\w\-. ]+\.(tsx?|jsx?|css|scss|json|md|py|java|go|rs|c|cpp|h|hpp|sh|ya?ml|toml|xml|html|vue|svelte)/i;
|
||||
|
||||
// Parse HTML into a DOM tree so we don't replace inside attributes
|
||||
const container = document.createElement('div');
|
||||
container.innerHTML = html;
|
||||
|
||||
const union = new RegExp(
|
||||
`${FILE_PATH_WITH_LINES_REGEX.source}|${FILE_PATH_REGEX.source}|${BARE_FILE_REGEX.source}`,
|
||||
'gi',
|
||||
);
|
||||
|
||||
// Convert a "path#fragment" into VS Code friendly "path:line" (we only keep the start line)
|
||||
const normalizePathAndLine = (
|
||||
raw: string,
|
||||
): { displayText: string; dataPath: string } => {
|
||||
const displayText = raw;
|
||||
let base = raw;
|
||||
// Extract hash fragment like #12, #L12 or #12-34 and keep only the first number
|
||||
const hashIndex = raw.indexOf('#');
|
||||
if (hashIndex >= 0) {
|
||||
const frag = raw.slice(hashIndex + 1);
|
||||
// Accept L12, 12 or 12-34
|
||||
const m = frag.match(/^L?(\d+)(?:-\d+)?$/i);
|
||||
if (m) {
|
||||
const line = parseInt(m[1], 10);
|
||||
base = raw.slice(0, hashIndex);
|
||||
return { displayText, dataPath: `${base}:${line}` };
|
||||
}
|
||||
}
|
||||
return { displayText, dataPath: base };
|
||||
};
|
||||
|
||||
const makeLink = (text: string) => {
|
||||
const link = document.createElement('a');
|
||||
// Pass base path (with optional :line) to the handler; keep the full text as label
|
||||
const { dataPath } = normalizePathAndLine(text);
|
||||
link.className = 'file-path-link';
|
||||
link.textContent = text;
|
||||
link.setAttribute('href', '#');
|
||||
link.setAttribute('title', `Open ${text}`);
|
||||
// Carry file path via data attribute; click handled by event delegation
|
||||
link.setAttribute('data-file-path', dataPath);
|
||||
return link;
|
||||
};
|
||||
|
||||
const upgradeAnchorIfFilePath = (a: HTMLAnchorElement) => {
|
||||
const href = a.getAttribute('href') || '';
|
||||
const text = (a.textContent || '').trim();
|
||||
|
||||
// Helper: identify dot-chained code refs (e.g. vscode.commands.register)
|
||||
// but DO NOT treat filenames/paths as code refs.
|
||||
const isCodeReference = (str: string): boolean => {
|
||||
if (BARE_FILE_REGEX.test(str)) {
|
||||
return false; // looks like a filename
|
||||
}
|
||||
if (/[/\\]/.test(str)) {
|
||||
return false; // contains a path separator
|
||||
}
|
||||
const codeRefPattern = /^[a-zA-Z_$][\w$]*(\.[a-zA-Z_$][\w$]*)+$/;
|
||||
return codeRefPattern.test(str);
|
||||
};
|
||||
|
||||
// If linkify turned a bare filename (e.g. README.md) into http://<filename>, convert it back
|
||||
const httpMatch = href.match(/^https?:\/\/(.+)$/i);
|
||||
if (httpMatch) {
|
||||
try {
|
||||
const url = new URL(href);
|
||||
const host = url.hostname || '';
|
||||
const pathname = url.pathname || '';
|
||||
const noPath = pathname === '' || pathname === '/';
|
||||
|
||||
// Case 1: anchor text itself is a bare filename and equals the host (e.g. README.md)
|
||||
if (
|
||||
noPath &&
|
||||
BARE_FILE_REGEX.test(text) &&
|
||||
host.toLowerCase() === text.toLowerCase()
|
||||
) {
|
||||
const { dataPath } = normalizePathAndLine(text);
|
||||
a.classList.add('file-path-link');
|
||||
a.setAttribute('href', '#');
|
||||
a.setAttribute('title', `Open ${text}`);
|
||||
a.setAttribute('data-file-path', dataPath);
|
||||
return;
|
||||
}
|
||||
|
||||
// Case 2: host itself looks like a filename (rare but happens), use it
|
||||
if (noPath && BARE_FILE_REGEX.test(host)) {
|
||||
const { dataPath } = normalizePathAndLine(host);
|
||||
a.classList.add('file-path-link');
|
||||
a.setAttribute('href', '#');
|
||||
a.setAttribute('title', `Open ${text || host}`);
|
||||
a.setAttribute('data-file-path', dataPath);
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// fall through; unparseable URL
|
||||
}
|
||||
}
|
||||
|
||||
// Ignore other external protocols
|
||||
if (/^(https?|mailto|ftp|data):/i.test(href)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const candidate = href || text;
|
||||
|
||||
// Skip if it looks like a code reference
|
||||
if (isCodeReference(candidate)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
FILE_PATH_WITH_LINES_NO_G.test(candidate) ||
|
||||
FILE_PATH_NO_G.test(candidate)
|
||||
) {
|
||||
const { dataPath } = normalizePathAndLine(candidate);
|
||||
a.classList.add('file-path-link');
|
||||
a.setAttribute('href', '#');
|
||||
a.setAttribute('title', `Open ${text || href}`);
|
||||
a.setAttribute('data-file-path', dataPath);
|
||||
return;
|
||||
}
|
||||
|
||||
// Bare file name or relative path (e.g. README.md or docs/README.md)
|
||||
if (BARE_FILE_REGEX.test(candidate)) {
|
||||
const { dataPath } = normalizePathAndLine(candidate);
|
||||
a.classList.add('file-path-link');
|
||||
a.setAttribute('href', '#');
|
||||
a.setAttribute('title', `Open ${text || href}`);
|
||||
a.setAttribute('data-file-path', dataPath);
|
||||
}
|
||||
};
|
||||
|
||||
// Helper: identify dot-chained code refs (e.g. vscode.commands.register)
|
||||
// but DO NOT treat filenames/paths as code refs.
|
||||
const isCodeReference = (str: string): boolean => {
|
||||
if (BARE_FILE_REGEX.test(str)) {
|
||||
return false; // looks like a filename
|
||||
}
|
||||
if (/[/\\]/.test(str)) {
|
||||
return false; // contains a path separator
|
||||
}
|
||||
const codeRefPattern = /^[a-zA-Z_$][\w$]*(\.[a-zA-Z_$][\w$]*)+$/;
|
||||
return codeRefPattern.test(str);
|
||||
};
|
||||
|
||||
const walk = (node: Node) => {
|
||||
// Do not transform inside existing anchors
|
||||
if (node.nodeType === Node.ELEMENT_NODE) {
|
||||
const el = node as HTMLElement;
|
||||
if (el.tagName.toLowerCase() === 'a') {
|
||||
upgradeAnchorIfFilePath(el as HTMLAnchorElement);
|
||||
return; // Don't descend into <a>
|
||||
}
|
||||
// Avoid transforming inside code/pre blocks
|
||||
const tag = el.tagName.toLowerCase();
|
||||
if (tag === 'code' || tag === 'pre') {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
for (let child = node.firstChild; child; ) {
|
||||
const next = child.nextSibling; // child may be replaced
|
||||
if (child.nodeType === Node.TEXT_NODE) {
|
||||
const text = child.nodeValue || '';
|
||||
union.lastIndex = 0;
|
||||
const hasMatch = union.test(text);
|
||||
union.lastIndex = 0;
|
||||
if (hasMatch) {
|
||||
const frag = document.createDocumentFragment();
|
||||
let lastIndex = 0;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = union.exec(text))) {
|
||||
const matchText = m[0];
|
||||
const idx = m.index;
|
||||
|
||||
// Skip if it looks like a code reference
|
||||
if (isCodeReference(matchText)) {
|
||||
// Just add the text as-is without creating a link
|
||||
if (idx > lastIndex) {
|
||||
frag.appendChild(
|
||||
document.createTextNode(text.slice(lastIndex, idx)),
|
||||
);
|
||||
}
|
||||
frag.appendChild(document.createTextNode(matchText));
|
||||
lastIndex = idx + matchText.length;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (idx > lastIndex) {
|
||||
frag.appendChild(
|
||||
document.createTextNode(text.slice(lastIndex, idx)),
|
||||
);
|
||||
}
|
||||
frag.appendChild(makeLink(matchText));
|
||||
lastIndex = idx + matchText.length;
|
||||
}
|
||||
if (lastIndex < text.length) {
|
||||
frag.appendChild(document.createTextNode(text.slice(lastIndex)));
|
||||
}
|
||||
node.replaceChild(frag, child);
|
||||
}
|
||||
} else if (child.nodeType === Node.ELEMENT_NODE) {
|
||||
walk(child);
|
||||
}
|
||||
child = next;
|
||||
}
|
||||
};
|
||||
|
||||
walk(container);
|
||||
return container.innerHTML;
|
||||
};
|
||||
|
||||
// Event delegation: intercept clicks on generated file-path links
|
||||
const handleContainerClick = (
|
||||
e: React.MouseEvent<HTMLDivElement, MouseEvent>,
|
||||
) => {
|
||||
// If file links disabled, do nothing
|
||||
if (!enableFileLinks) {
|
||||
return;
|
||||
}
|
||||
const target = e.target as HTMLElement | null;
|
||||
if (!target) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Find nearest anchor with our marker class
|
||||
const anchor = (target.closest &&
|
||||
target.closest('a.file-path-link')) as HTMLAnchorElement | null;
|
||||
if (anchor) {
|
||||
const filePath = anchor.getAttribute('data-file-path');
|
||||
if (!filePath) {
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onFileClick?.(filePath);
|
||||
return;
|
||||
}
|
||||
|
||||
// Fallback: intercept "http://README.md" style links that slipped through
|
||||
const anyAnchor = (target.closest &&
|
||||
target.closest('a')) as HTMLAnchorElement | null;
|
||||
if (!anyAnchor) {
|
||||
return;
|
||||
}
|
||||
|
||||
const href = anyAnchor.getAttribute('href') || '';
|
||||
if (!/^https?:\/\//i.test(href)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const url = new URL(href);
|
||||
const host = url.hostname || '';
|
||||
const path = url.pathname || '';
|
||||
const noPath = path === '' || path === '/';
|
||||
|
||||
// Basic bare filename heuristic on the host part (e.g. README.md)
|
||||
if (noPath && /\.[a-z0-9]+$/i.test(host)) {
|
||||
// Prefer the readable text content if it looks like a file
|
||||
const text = (anyAnchor.textContent || '').trim();
|
||||
const candidate = /\.[a-z0-9]+$/i.test(text) ? text : host;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onFileClick?.(candidate);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="markdown-content"
|
||||
onClick={handleContainerClick}
|
||||
dangerouslySetInnerHTML={{ __html: renderMarkdown() }}
|
||||
style={{
|
||||
wordWrap: 'break-word',
|
||||
overflowWrap: 'break-word',
|
||||
whiteSpace: 'normal',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { MarkdownRenderer } from './MarkdownRenderer/MarkdownRenderer.js';
|
||||
|
||||
interface MessageContentProps {
|
||||
content: string;
|
||||
onFileClick?: (filePath: string) => void;
|
||||
enableFileLinks?: boolean;
|
||||
}
|
||||
|
||||
export const MessageContent: React.FC<MessageContentProps> = ({
|
||||
content,
|
||||
onFileClick,
|
||||
enableFileLinks,
|
||||
}) => (
|
||||
<MarkdownRenderer
|
||||
content={content}
|
||||
onFileClick={onFileClick}
|
||||
enableFileLinks={enableFileLinks}
|
||||
/>
|
||||
);
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { MessageContent } from './MessageContent.js';
|
||||
|
||||
interface ThinkingMessageProps {
|
||||
content: string;
|
||||
timestamp: number;
|
||||
onFileClick?: (path: string) => void;
|
||||
}
|
||||
|
||||
export const ThinkingMessage: React.FC<ThinkingMessageProps> = ({
|
||||
content,
|
||||
timestamp: _timestamp,
|
||||
onFileClick,
|
||||
}) => (
|
||||
<div className="qwen-message thinking-message flex gap-0 items-start text-left py-2 flex-col relative opacity-80 italic pl-6 animate-[fadeIn_0.2s_ease-in]">
|
||||
<div
|
||||
className="inline-block my-1 relative whitespace-pre-wrap rounded-md max-w-full overflow-x-auto overflow-y-hidden select-text leading-[1.5]"
|
||||
style={{
|
||||
backgroundColor:
|
||||
'var(--app-list-hover-background, rgba(100, 100, 255, 0.1))',
|
||||
border: '1px solid rgba(100, 100, 255, 0.3)',
|
||||
borderRadius: 'var(--corner-radius-medium)',
|
||||
padding: 'var(--app-spacing-medium)',
|
||||
color: 'var(--app-primary-foreground)',
|
||||
}}
|
||||
>
|
||||
<span className="inline-flex items-center gap-1 mr-2">
|
||||
<span className="inline-block w-1.5 h-1.5 bg-[var(--app-secondary-foreground)] rounded-full opacity-60 animate-[typingPulse_1.4s_infinite_ease-in-out] [animation-delay:0s]"></span>
|
||||
<span className="inline-block w-1.5 h-1.5 bg-[var(--app-secondary-foreground)] rounded-full opacity-60 animate-[typingPulse_1.4s_infinite_ease-in-out] [animation-delay:0.2s]"></span>
|
||||
<span className="inline-block w-1.5 h-1.5 bg-[var(--app-secondary-foreground)] rounded-full opacity-60 animate-[typingPulse_1.4s_infinite_ease-in-out] [animation-delay:0.4s]"></span>
|
||||
</span>
|
||||
<MessageContent content={content} onFileClick={onFileClick} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { MessageContent } from './MessageContent.js';
|
||||
|
||||
interface FileContext {
|
||||
fileName: string;
|
||||
filePath: string;
|
||||
startLine?: number;
|
||||
endLine?: number;
|
||||
}
|
||||
|
||||
interface UserMessageProps {
|
||||
content: string;
|
||||
timestamp: number;
|
||||
onFileClick?: (path: string) => void;
|
||||
fileContext?: FileContext;
|
||||
}
|
||||
|
||||
export const UserMessage: React.FC<UserMessageProps> = ({
|
||||
content,
|
||||
timestamp: _timestamp,
|
||||
onFileClick,
|
||||
fileContext,
|
||||
}) => {
|
||||
// Generate display text for file context
|
||||
const getFileContextDisplay = () => {
|
||||
if (!fileContext) {
|
||||
return null;
|
||||
}
|
||||
const { fileName, startLine, endLine } = fileContext;
|
||||
if (startLine && endLine) {
|
||||
return startLine === endLine
|
||||
? `${fileName}#${startLine}`
|
||||
: `${fileName}#${startLine}-${endLine}`;
|
||||
}
|
||||
return fileName;
|
||||
};
|
||||
|
||||
const fileContextDisplay = getFileContextDisplay();
|
||||
|
||||
return (
|
||||
<div
|
||||
className="qwen-message user-message-container flex gap-0 my-1 items-start text-left flex-col relative"
|
||||
style={{ position: 'relative' }}
|
||||
>
|
||||
<div
|
||||
className="inline-block relative whitespace-pre-wrap rounded-md max-w-full overflow-x-auto overflow-y-hidden select-text leading-[1.5]"
|
||||
style={{
|
||||
border: '1px solid var(--app-input-border)',
|
||||
borderRadius: 'var(--corner-radius-medium)',
|
||||
backgroundColor: 'var(--app-input-background)',
|
||||
padding: '4px 6px',
|
||||
color: 'var(--app-primary-foreground)',
|
||||
}}
|
||||
>
|
||||
{/* For user messages, do NOT convert filenames to clickable links */}
|
||||
<MessageContent
|
||||
content={content}
|
||||
onFileClick={onFileClick}
|
||||
enableFileLinks={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* File context indicator */}
|
||||
{fileContextDisplay && (
|
||||
<div className="mt-1">
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className="mr inline-flex items-center py-0 pr-2 gap-1 rounded-sm cursor-pointer relative opacity-50"
|
||||
onClick={() => fileContext && onFileClick?.(fileContext.filePath)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
fileContext && onFileClick?.(fileContext.filePath);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="gr"
|
||||
title={fileContextDisplay}
|
||||
style={{
|
||||
fontSize: '12px',
|
||||
color: 'var(--app-secondary-foreground)',
|
||||
}}
|
||||
>
|
||||
{fileContextDisplay}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
|
||||
interface InterruptedMessageProps {
|
||||
text?: string;
|
||||
}
|
||||
|
||||
// A lightweight status line similar to WaitingMessage but without the left status icon.
|
||||
export const InterruptedMessage: React.FC<InterruptedMessageProps> = ({
|
||||
text = 'Interrupted',
|
||||
}) => (
|
||||
<div className="flex gap-0 items-start text-left py-2 flex-col opacity-85">
|
||||
<div className="interrupted-item w-full relative">
|
||||
<span className="opacity-70 italic">{text}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
@import url('../Assistant/AssistantMessage.css');
|
||||
|
||||
/* Subtle shimmering highlight across the loading text */
|
||||
@keyframes waitingMessageShimmer {
|
||||
0% {
|
||||
background-position: -200% 0;
|
||||
}
|
||||
100% {
|
||||
background-position: 200% 0;
|
||||
}
|
||||
}
|
||||
|
||||
.loading-text-shimmer {
|
||||
/* Use the theme foreground as the base color, with a moving light band */
|
||||
background-image: linear-gradient(
|
||||
90deg,
|
||||
var(--app-secondary-foreground) 0%,
|
||||
var(--app-secondary-foreground) 40%,
|
||||
rgba(255, 255, 255, 0.95) 50%,
|
||||
var(--app-secondary-foreground) 60%,
|
||||
var(--app-secondary-foreground) 100%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent; /* text color comes from the gradient */
|
||||
animation: waitingMessageShimmer 1.6s linear infinite;
|
||||
}
|
||||
|
||||
.interrupted-item::after {
|
||||
display: none;
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import './WaitingMessage.css';
|
||||
import { WITTY_LOADING_PHRASES } from '../../../../constants/loadingMessages.js';
|
||||
|
||||
interface WaitingMessageProps {
|
||||
loadingMessage: string;
|
||||
}
|
||||
|
||||
// Rotate message every few seconds while waiting
|
||||
const ROTATE_INTERVAL_MS = 3000; // rotate every 3s per request
|
||||
|
||||
export const WaitingMessage: React.FC<WaitingMessageProps> = ({
|
||||
loadingMessage,
|
||||
}) => {
|
||||
// Build a phrase list that starts with the provided message (if any), then witty fallbacks
|
||||
const phrases = useMemo(() => {
|
||||
const set = new Set<string>();
|
||||
const list: string[] = [];
|
||||
if (loadingMessage && loadingMessage.trim()) {
|
||||
list.push(loadingMessage);
|
||||
set.add(loadingMessage);
|
||||
}
|
||||
for (const p of WITTY_LOADING_PHRASES) {
|
||||
if (!set.has(p)) {
|
||||
list.push(p);
|
||||
}
|
||||
}
|
||||
return list;
|
||||
}, [loadingMessage]);
|
||||
|
||||
const [index, setIndex] = useState(0);
|
||||
|
||||
// Reset to the first phrase whenever the incoming message changes
|
||||
useEffect(() => {
|
||||
setIndex(0);
|
||||
}, [phrases]);
|
||||
|
||||
// Periodically rotate to a different phrase
|
||||
useEffect(() => {
|
||||
if (phrases.length <= 1) {
|
||||
return;
|
||||
}
|
||||
const id = setInterval(() => {
|
||||
setIndex((prev) => {
|
||||
// pick a different random index to avoid immediate repeats
|
||||
let next = Math.floor(Math.random() * phrases.length);
|
||||
if (phrases.length > 1) {
|
||||
let guard = 0;
|
||||
while (next === prev && guard < 5) {
|
||||
next = Math.floor(Math.random() * phrases.length);
|
||||
guard++;
|
||||
}
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, ROTATE_INTERVAL_MS);
|
||||
return () => clearInterval(id);
|
||||
}, [phrases]);
|
||||
|
||||
return (
|
||||
<div className="waiting-message-outer flex gap-0 items-start text-left py-2 flex-col opacity-85">
|
||||
{/* Use the same left status icon (pseudo-element) style as assistant-message-container */}
|
||||
<div className="assistant-message-container assistant-message-loading waiting-message-inner w-full items-start pl-[30px] relative">
|
||||
<span className="waiting-message-text opacity-70 italic loading-text-shimmer">
|
||||
{phrases[index]}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
export { UserMessage } from './UserMessage.js';
|
||||
export { AssistantMessage } from './Assistant/AssistantMessage.js';
|
||||
export { ThinkingMessage } from './ThinkingMessage.js';
|
||||
export { WaitingMessage } from './Waiting/WaitingMessage.js';
|
||||
export { InterruptedMessage } from './Waiting/InterruptedMessage.js';
|
||||
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*
|
||||
* Execute tool call styles - Enhanced styling with semantic class names
|
||||
*/
|
||||
|
||||
/* Root container for execute tool call output */
|
||||
.bash-toolcall-card {
|
||||
border: 0.5px solid var(--app-input-border);
|
||||
border-radius: 5px;
|
||||
background: var(--app-tool-background);
|
||||
margin: 8px 0;
|
||||
max-width: 100%;
|
||||
font-size: 1em;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
/* Content wrapper inside the card */
|
||||
.bash-toolcall-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
/* Individual input/output row */
|
||||
.bash-toolcall-row {
|
||||
display: grid;
|
||||
grid-template-columns: max-content 1fr;
|
||||
border-top: 0.5px solid var(--app-input-border);
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
/* First row has no top border */
|
||||
.bash-toolcall-row:first-child {
|
||||
border-top: none;
|
||||
}
|
||||
|
||||
/* Row label (IN/OUT/ERROR) */
|
||||
.bash-toolcall-label {
|
||||
grid-column: 1;
|
||||
color: var(--app-secondary-foreground);
|
||||
text-align: left;
|
||||
opacity: 50%;
|
||||
padding: 4px 8px 4px 4px;
|
||||
font-family: var(--app-monospace-font-family);
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
/* Row content area */
|
||||
.bash-toolcall-row-content {
|
||||
grid-column: 2;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
margin: 0;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
/* Truncated content styling */
|
||||
.bash-toolcall-row-content:not(.bash-toolcall-full) {
|
||||
max-height: 60px;
|
||||
mask-image: linear-gradient(
|
||||
to bottom,
|
||||
var(--app-primary-background) 40px,
|
||||
transparent 60px
|
||||
);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Preformatted content */
|
||||
.bash-toolcall-pre {
|
||||
margin-block: 0;
|
||||
overflow: hidden;
|
||||
font-family: var(--app-monospace-font-family);
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
/* Code content */
|
||||
.bash-toolcall-code {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-family: var(--app-monospace-font-family);
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
/* Output content with subtle styling */
|
||||
.bash-toolcall-output-subtle {
|
||||
background-color: var(--app-code-background);
|
||||
white-space: pre;
|
||||
overflow-x: auto;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* Error content styling */
|
||||
.bash-toolcall-error-content {
|
||||
color: #c74e39;
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*
|
||||
* Execute tool call component - specialized for command execution operations
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import type { BaseToolCallProps } from '../shared/types.js';
|
||||
import { ToolCallContainer } from '../shared/LayoutComponents.js';
|
||||
import { safeTitle, groupContent } from '../shared/utils.js';
|
||||
import { useVSCode } from '../../../../hooks/useVSCode.js';
|
||||
import { createAndOpenTempFile } from '../../../../utils/tempFileManager.js';
|
||||
import './Bash.css';
|
||||
|
||||
/**
|
||||
* Specialized component for Execute/Bash tool calls
|
||||
* Shows: Bash bullet + description + IN/OUT card
|
||||
*/
|
||||
export const ExecuteToolCall: React.FC<BaseToolCallProps> = ({ toolCall }) => {
|
||||
const { title, content, rawInput, toolCallId } = toolCall;
|
||||
const commandText = safeTitle(title);
|
||||
const vscode = useVSCode();
|
||||
|
||||
// Group content by type
|
||||
const { textOutputs, errors } = groupContent(content);
|
||||
|
||||
// Extract command from rawInput if available
|
||||
let inputCommand = commandText;
|
||||
if (rawInput && typeof rawInput === 'object') {
|
||||
const inputObj = rawInput as { command?: string };
|
||||
inputCommand = inputObj.command || commandText;
|
||||
} else if (typeof rawInput === 'string') {
|
||||
inputCommand = rawInput;
|
||||
}
|
||||
|
||||
// Handle click on IN section
|
||||
const handleInClick = () => {
|
||||
createAndOpenTempFile(
|
||||
vscode.postMessage,
|
||||
inputCommand,
|
||||
'bash-input',
|
||||
'.sh',
|
||||
);
|
||||
};
|
||||
|
||||
// Handle click on OUT section
|
||||
const handleOutClick = () => {
|
||||
if (textOutputs.length > 0) {
|
||||
const output = textOutputs.join('\n');
|
||||
createAndOpenTempFile(vscode.postMessage, output, 'bash-output', '.txt');
|
||||
}
|
||||
};
|
||||
|
||||
// Map tool status to container status for proper bullet coloring
|
||||
const containerStatus:
|
||||
| 'success'
|
||||
| 'error'
|
||||
| 'warning'
|
||||
| 'loading'
|
||||
| 'default' =
|
||||
errors.length > 0
|
||||
? 'error'
|
||||
: toolCall.status === 'in_progress' || toolCall.status === 'pending'
|
||||
? 'loading'
|
||||
: 'success';
|
||||
|
||||
// Error case
|
||||
if (errors.length > 0) {
|
||||
return (
|
||||
<ToolCallContainer
|
||||
label="Bash"
|
||||
status={containerStatus}
|
||||
toolCallId={toolCallId}
|
||||
>
|
||||
{/* Branch connector summary */}
|
||||
<div className="inline-flex text-[var(--app-secondary-foreground)] text-[0.85em] opacity-70 mt-[2px] mb-[2px] flex-row items-start w-full gap-1">
|
||||
<span className="flex-shrink-0 relative top-[-0.1em]">⎿</span>
|
||||
<span className="flex-shrink-0 w-full">{commandText}</span>
|
||||
</div>
|
||||
{/* Error card - semantic DOM + Tailwind styles */}
|
||||
<div className="bash-toolcall-card">
|
||||
<div className="bash-toolcall-content">
|
||||
{/* IN row */}
|
||||
<div
|
||||
className="bash-toolcall-row"
|
||||
onClick={handleInClick}
|
||||
style={{ cursor: 'pointer' }}
|
||||
>
|
||||
<div className="bash-toolcall-label">IN</div>
|
||||
<div className="bash-toolcall-row-content">
|
||||
<pre className="bash-toolcall-pre">{inputCommand}</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ERROR row */}
|
||||
<div className="bash-toolcall-row">
|
||||
<div className="bash-toolcall-label">Error</div>
|
||||
<div className="bash-toolcall-row-content">
|
||||
<pre className="bash-toolcall-pre bash-toolcall-error-content">
|
||||
{errors.join('\n')}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ToolCallContainer>
|
||||
);
|
||||
}
|
||||
|
||||
// Success with output
|
||||
if (textOutputs.length > 0) {
|
||||
const output = textOutputs.join('\n');
|
||||
const truncatedOutput =
|
||||
output.length > 500 ? output.substring(0, 500) + '...' : output;
|
||||
|
||||
return (
|
||||
<ToolCallContainer
|
||||
label="Bash"
|
||||
status={containerStatus}
|
||||
toolCallId={toolCallId}
|
||||
>
|
||||
{/* Branch connector summary */}
|
||||
<div className="inline-flex text-[var(--app-secondary-foreground)] text-[0.85em] opacity-70 mt-[2px] mb-[2px] flex-row items-start w-full gap-1">
|
||||
<span className="flex-shrink-0 relative top-[-0.1em]">⎿</span>
|
||||
<span className="flex-shrink-0 w-full">{commandText}</span>
|
||||
</div>
|
||||
{/* Output card - semantic DOM + Tailwind styles */}
|
||||
<div className="bash-toolcall-card">
|
||||
<div className="bash-toolcall-content">
|
||||
{/* IN row */}
|
||||
<div
|
||||
className="bash-toolcall-row"
|
||||
onClick={handleInClick}
|
||||
style={{ cursor: 'pointer' }}
|
||||
>
|
||||
<div className="bash-toolcall-label">IN</div>
|
||||
<div className="bash-toolcall-row-content">
|
||||
<pre className="bash-toolcall-pre">{inputCommand}</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* OUT row */}
|
||||
<div
|
||||
className="bash-toolcall-row"
|
||||
onClick={handleOutClick}
|
||||
style={{ cursor: 'pointer' }}
|
||||
>
|
||||
<div className="bash-toolcall-label">OUT</div>
|
||||
<div className="bash-toolcall-row-content">
|
||||
<div className="bash-toolcall-output-subtle">
|
||||
<pre className="bash-toolcall-pre">{truncatedOutput}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ToolCallContainer>
|
||||
);
|
||||
}
|
||||
|
||||
// Success without output: show command with branch connector
|
||||
return (
|
||||
<ToolCallContainer
|
||||
label="Bash"
|
||||
status={containerStatus}
|
||||
toolCallId={toolCallId}
|
||||
>
|
||||
<div
|
||||
className="inline-flex text-[var(--app-secondary-foreground)] text-[0.85em] opacity-70 mt-[2px] mb-[2px] flex-row items-start w-full gap-1"
|
||||
onClick={handleInClick}
|
||||
style={{ cursor: 'pointer' }}
|
||||
>
|
||||
<span className="flex-shrink-0 relative top-[-0.1em]">⎿</span>
|
||||
<span className="flex-shrink-0 w-full">{commandText}</span>
|
||||
</div>
|
||||
</ToolCallContainer>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,196 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*
|
||||
* Edit tool call component - specialized for file editing operations
|
||||
*/
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import type { BaseToolCallProps } from '../shared/types.js';
|
||||
import {
|
||||
groupContent,
|
||||
mapToolStatusToContainerStatus,
|
||||
} from '../shared/utils.js';
|
||||
import { FileLink } from '../../../layout/FileLink.js';
|
||||
import type { ToolCallContainerProps } from '../shared/LayoutComponents.js';
|
||||
|
||||
export const ToolCallContainer: React.FC<ToolCallContainerProps> = ({
|
||||
label,
|
||||
status = 'success',
|
||||
children,
|
||||
toolCallId: _toolCallId,
|
||||
labelSuffix,
|
||||
className: _className,
|
||||
}) => (
|
||||
<div
|
||||
className={`qwen-message message-item ${_className || ''} relative pl-[30px] py-2 select-text toolcall-container toolcall-status-${status}`}
|
||||
>
|
||||
<div className="EditToolCall toolcall-content-wrapper flex flex-col gap-1 min-w-0 max-w-full">
|
||||
<div className="flex items-baseline gap-1.5 relative min-w-0">
|
||||
<span className="text-[14px] leading-none font-bold text-[var(--app-primary-foreground)]">
|
||||
{label}
|
||||
</span>
|
||||
<span className="text-[11px] text-[var(--app-secondary-foreground)]">
|
||||
{labelSuffix}
|
||||
</span>
|
||||
</div>
|
||||
{children && (
|
||||
<div className="text-[var(--app-secondary-foreground)]">{children}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
/**
|
||||
* Calculate diff summary (added/removed lines)
|
||||
*/
|
||||
const getDiffSummary = (
|
||||
oldText: string | null | undefined,
|
||||
newText: string | undefined,
|
||||
): string => {
|
||||
const oldLines = oldText ? oldText.split('\n').length : 0;
|
||||
const newLines = newText ? newText.split('\n').length : 0;
|
||||
const diff = newLines - oldLines;
|
||||
|
||||
if (diff > 0) {
|
||||
return `+${diff} lines`;
|
||||
} else if (diff < 0) {
|
||||
return `${diff} lines`;
|
||||
} else {
|
||||
return 'Modified';
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Specialized component for Edit tool calls
|
||||
* Optimized for displaying file editing operations with diffs
|
||||
*/
|
||||
export const EditToolCall: React.FC<BaseToolCallProps> = ({ toolCall }) => {
|
||||
const { content, locations, toolCallId } = toolCall;
|
||||
|
||||
// Group content by type; memoize to avoid new array identities on every render
|
||||
const { errors, diffs } = useMemo(() => groupContent(content), [content]);
|
||||
|
||||
// Failed case: show explicit failed message and render inline diffs
|
||||
if (toolCall.status === 'failed') {
|
||||
const firstDiff = diffs[0];
|
||||
const path = firstDiff?.path || locations?.[0]?.path || '';
|
||||
const containerStatus = mapToolStatusToContainerStatus(toolCall.status);
|
||||
return (
|
||||
<div
|
||||
className={`qwen-message message-item relative py-2 select-text toolcall-container toolcall-status-${containerStatus}`}
|
||||
>
|
||||
<div className="toolcall-edit-content flex flex-col gap-1 min-w-0 max-w-full">
|
||||
<div className="flex items-center justify-between min-w-0">
|
||||
<div className="flex items-baseline gap-2 min-w-0">
|
||||
<span className="text-[13px] leading-none font-bold text-[var(--app-primary-foreground)]">
|
||||
Edit
|
||||
</span>
|
||||
{path && (
|
||||
<FileLink
|
||||
path={path}
|
||||
showFullPath={false}
|
||||
className="font-mono text-[var(--app-secondary-foreground)] hover:underline"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{/* Failed state text (replace summary) */}
|
||||
<div className="inline-flex text-[var(--app-secondary-foreground)] text-[0.85em] opacity-70 flex-row items-start w-full gap-1 flex items-center">
|
||||
<span className="flex-shrink-0 w-full">edit failed</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Error case: show error
|
||||
if (errors.length > 0) {
|
||||
const path = diffs[0]?.path || locations?.[0]?.path || '';
|
||||
return (
|
||||
<ToolCallContainer
|
||||
label={'Edit'}
|
||||
status="error"
|
||||
toolCallId={toolCallId}
|
||||
labelSuffix={
|
||||
path ? (
|
||||
<FileLink
|
||||
path={path}
|
||||
showFullPath={false}
|
||||
className="text-xs font-mono hover:underline"
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
{errors.join('\n')}
|
||||
</ToolCallContainer>
|
||||
);
|
||||
}
|
||||
|
||||
// Success case with diff: show minimal inline preview; clicking the title opens VS Code diff
|
||||
if (diffs.length > 0) {
|
||||
const firstDiff = diffs[0];
|
||||
const path = firstDiff.path || (locations && locations[0]?.path) || '';
|
||||
const summary = getDiffSummary(firstDiff.oldText, firstDiff.newText);
|
||||
const containerStatus = mapToolStatusToContainerStatus(toolCall.status);
|
||||
return (
|
||||
<div
|
||||
className={`qwen-message message-item relative py-2 select-text toolcall-container toolcall-status-${containerStatus}`}
|
||||
>
|
||||
<div className="toolcall-edit-content flex flex-col gap-1 min-w-0 max-w-full">
|
||||
<div className="flex items-center justify-between min-w-0">
|
||||
<div className="flex items-baseline gap-1.5 min-w-0">
|
||||
{/* Align the inline Edit label styling with shared toolcall label: larger + bold */}
|
||||
<span className="text-[13px] leading-none font-bold text-[var(--app-primary-foreground)]">
|
||||
Edit
|
||||
</span>
|
||||
{path && (
|
||||
<FileLink
|
||||
path={path}
|
||||
showFullPath={false}
|
||||
className="font-mono text-[var(--app-secondary-foreground)] hover:underline"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="inline-flex text-[var(--app-secondary-foreground)] text-[0.85em] opacity-70 flex-row items-start w-full gap-1 flex items-baseline">
|
||||
<span className="flex-shrink-0 relative top-[-0.1em]">⎿</span>
|
||||
<span className="flex-shrink-0 w-full">{summary}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Success case without diff: show file in compact format
|
||||
if (locations && locations.length > 0) {
|
||||
const containerStatus = mapToolStatusToContainerStatus(toolCall.status);
|
||||
return (
|
||||
<ToolCallContainer
|
||||
label={`Edit`}
|
||||
status={containerStatus}
|
||||
toolCallId={toolCallId}
|
||||
labelSuffix={
|
||||
<FileLink
|
||||
path={locations[0].path}
|
||||
showFullPath={false}
|
||||
className="text-xs font-mono text-[var(--app-secondary-foreground)] hover:underline"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div className="inline-flex text-[var(--app-secondary-foreground)] text-[0.85em] opacity-70 flex-row items-start w-full gap-1 flex items-center">
|
||||
<span className="flex-shrink-0 relative top-[-0.1em]">⎿</span>
|
||||
<FileLink
|
||||
path={locations[0].path}
|
||||
line={locations[0].line}
|
||||
showFullPath={true}
|
||||
/>
|
||||
</div>
|
||||
</ToolCallContainer>
|
||||
);
|
||||
}
|
||||
|
||||
// No output, don't show anything
|
||||
return null;
|
||||
};
|
||||
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*
|
||||
* Execute tool call styles - Enhanced styling with semantic class names
|
||||
*/
|
||||
|
||||
/* Root container for execute tool call output */
|
||||
.execute-toolcall-card {
|
||||
border: 0.5px solid var(--app-input-border);
|
||||
border-radius: 5px;
|
||||
background: var(--app-tool-background);
|
||||
margin: 8px 0;
|
||||
max-width: 100%;
|
||||
font-size: 1em;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
/* Content wrapper inside the card */
|
||||
.execute-toolcall-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
/* Individual input/output row */
|
||||
.execute-toolcall-row {
|
||||
display: grid;
|
||||
grid-template-columns: max-content 1fr;
|
||||
border-top: 0.5px solid var(--app-input-border);
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
/* First row has no top border */
|
||||
.execute-toolcall-row:first-child {
|
||||
border-top: none;
|
||||
}
|
||||
|
||||
/* Row label (IN/OUT/ERROR) */
|
||||
.execute-toolcall-label {
|
||||
grid-column: 1;
|
||||
color: var(--app-secondary-foreground);
|
||||
text-align: left;
|
||||
opacity: 50%;
|
||||
padding: 4px 8px 4px 4px;
|
||||
font-family: var(--app-monospace-font-family);
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
/* Row content area */
|
||||
.execute-toolcall-row-content {
|
||||
grid-column: 2;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
margin: 0;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
/* Truncated content styling */
|
||||
.execute-toolcall-row-content:not(.execute-toolcall-full) {
|
||||
max-height: 60px;
|
||||
mask-image: linear-gradient(
|
||||
to bottom,
|
||||
var(--app-primary-background) 40px,
|
||||
transparent 60px
|
||||
);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Preformatted content */
|
||||
.execute-toolcall-pre {
|
||||
margin-block: 0;
|
||||
overflow: hidden;
|
||||
font-family: var(--app-monospace-font-family);
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
/* Code content */
|
||||
.execute-toolcall-code {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-family: var(--app-monospace-font-family);
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
/* Output content with subtle styling */
|
||||
.execute-toolcall-output-subtle {
|
||||
background-color: var(--app-code-background);
|
||||
white-space: pre;
|
||||
overflow-x: auto;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* Error content styling */
|
||||
.execute-toolcall-error-content {
|
||||
color: #c74e39;
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*
|
||||
* Execute tool call component - specialized for command execution operations
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import type { BaseToolCallProps } from '../shared/types.js';
|
||||
import { safeTitle, groupContent } from '../shared/utils.js';
|
||||
import './Execute.css';
|
||||
import type { ToolCallContainerProps } from '../shared/LayoutComponents.js';
|
||||
|
||||
export const ToolCallContainer: React.FC<ToolCallContainerProps> = ({
|
||||
label,
|
||||
status = 'success',
|
||||
children,
|
||||
toolCallId: _toolCallId,
|
||||
labelSuffix,
|
||||
className: _className,
|
||||
}) => (
|
||||
<div
|
||||
className={`ExecuteToolCall qwen-message message-item ${_className || ''} relative pl-[30px] py-2 select-text toolcall-container toolcall-status-${status}`}
|
||||
>
|
||||
<div className="toolcall-content-wrapper flex flex-col gap-0 min-w-0 max-w-full">
|
||||
<div className="flex items-baseline gap-1.5 relative min-w-0">
|
||||
<span className="text-[14px] leading-none font-bold text-[var(--app-primary-foreground)]">
|
||||
{label}
|
||||
</span>
|
||||
<span className="text-[11px] text-[var(--app-secondary-foreground)]">
|
||||
{labelSuffix}
|
||||
</span>
|
||||
</div>
|
||||
{children && (
|
||||
<div className="text-[var(--app-secondary-foreground)]">{children}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
/**
|
||||
* Specialized component for Execute tool calls
|
||||
* Shows: Execute bullet + description + IN/OUT card
|
||||
*/
|
||||
export const ExecuteToolCall: React.FC<BaseToolCallProps> = ({ toolCall }) => {
|
||||
const { title, content, rawInput, toolCallId } = toolCall;
|
||||
const commandText = safeTitle(
|
||||
(rawInput as Record<string, unknown>)?.description || title,
|
||||
);
|
||||
|
||||
// Group content by type
|
||||
const { textOutputs, errors } = groupContent(content);
|
||||
|
||||
// Extract command from rawInput if available
|
||||
let inputCommand = commandText;
|
||||
if (rawInput && typeof rawInput === 'object') {
|
||||
const inputObj = rawInput as Record<string, unknown>;
|
||||
inputCommand = (inputObj.command as string | undefined) || commandText;
|
||||
} else if (typeof rawInput === 'string') {
|
||||
inputCommand = rawInput;
|
||||
}
|
||||
|
||||
// Map tool status to container status for proper bullet coloring
|
||||
const containerStatus:
|
||||
| 'success'
|
||||
| 'error'
|
||||
| 'warning'
|
||||
| 'loading'
|
||||
| 'default' =
|
||||
errors.length > 0 || toolCall.status === 'failed'
|
||||
? 'error'
|
||||
: toolCall.status === 'in_progress' || toolCall.status === 'pending'
|
||||
? 'loading'
|
||||
: 'success';
|
||||
|
||||
// Error case
|
||||
if (errors.length > 0) {
|
||||
return (
|
||||
<ToolCallContainer
|
||||
label="Execute"
|
||||
status={containerStatus}
|
||||
toolCallId={toolCallId}
|
||||
className="execute-default-toolcall"
|
||||
>
|
||||
{/* Branch connector summary */}
|
||||
<div className="inline-flex text-[var(--app-secondary-foreground)] text-[0.85em] opacity-70 mt-[2px] mb-[2px] flex-row items-start w-full gap-1">
|
||||
<span className="flex-shrink-0 relative top-[-0.1em]">⎿</span>
|
||||
<span className="flex-shrink-0 w-full">{commandText}</span>
|
||||
</div>
|
||||
{/* Error card - semantic DOM + Tailwind styles */}
|
||||
<div className="execute-toolcall-card">
|
||||
<div className="execute-toolcall-content">
|
||||
{/* IN row */}
|
||||
<div className="execute-toolcall-row">
|
||||
<div className="execute-toolcall-label">IN</div>
|
||||
<div className="execute-toolcall-row-content">
|
||||
<pre className="execute-toolcall-pre">{inputCommand}</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ERROR row */}
|
||||
<div className="execute-toolcall-row">
|
||||
<div className="execute-toolcall-label">Error</div>
|
||||
<div className="execute-toolcall-row-content">
|
||||
<pre className="execute-toolcall-pre execute-toolcall-error-content">
|
||||
{errors.join('\n')}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ToolCallContainer>
|
||||
);
|
||||
}
|
||||
|
||||
// Success with output
|
||||
if (textOutputs.length > 0) {
|
||||
const output = textOutputs.join('\n');
|
||||
const truncatedOutput =
|
||||
output.length > 500 ? output.substring(0, 500) + '...' : output;
|
||||
|
||||
return (
|
||||
<ToolCallContainer
|
||||
label="Execute"
|
||||
status={containerStatus}
|
||||
toolCallId={toolCallId}
|
||||
>
|
||||
{/* Branch connector summary */}
|
||||
<div className="inline-flex text-[var(--app-secondary-foreground)] text-[0.85em] opacity-70 mt-[2px] mb-[2px] flex-row items-start w-full gap-1">
|
||||
<span className="flex-shrink-0 relative top-[-0.1em]">⎿</span>
|
||||
<span className="flex-shrink-0 w-full">{commandText}</span>
|
||||
</div>
|
||||
{/* Output card - semantic DOM + Tailwind styles */}
|
||||
<div className="execute-toolcall-card">
|
||||
<div className="execute-toolcall-content">
|
||||
{/* IN row */}
|
||||
<div className="execute-toolcall-row">
|
||||
<div className="execute-toolcall-label">IN</div>
|
||||
<div className="execute-toolcall-row-content">
|
||||
<pre className="execute-toolcall-pre">{inputCommand}</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* OUT row */}
|
||||
<div className="execute-toolcall-row">
|
||||
<div className="execute-toolcall-label">OUT</div>
|
||||
<div className="execute-toolcall-row-content">
|
||||
<div className="execute-toolcall-output-subtle">
|
||||
<pre className="execute-toolcall-pre">{truncatedOutput}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ToolCallContainer>
|
||||
);
|
||||
}
|
||||
|
||||
// Success without output: show command with branch connector
|
||||
return (
|
||||
<ToolCallContainer
|
||||
label="Execute"
|
||||
status={containerStatus}
|
||||
toolCallId={toolCallId}
|
||||
>
|
||||
<div className="inline-flex text-[var(--app-secondary-foreground)] text-[0.85em] opacity-70 mt-[2px] mb-[2px] flex-row items-start w-full gap-1">
|
||||
<span className="flex-shrink-0 relative top-[-0.1em]">⎿</span>
|
||||
<span className="flex-shrink-0 w-full">{commandText}</span>
|
||||
</div>
|
||||
</ToolCallContainer>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*
|
||||
* Generic tool call component - handles all tool call types as fallback
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import type { BaseToolCallProps } from './shared/types.js';
|
||||
import {
|
||||
ToolCallContainer,
|
||||
ToolCallCard,
|
||||
ToolCallRow,
|
||||
LocationsList,
|
||||
} from './shared/LayoutComponents.js';
|
||||
import { safeTitle, groupContent } from './shared/utils.js';
|
||||
|
||||
/**
|
||||
* Generic tool call component that can display any tool call type
|
||||
* Used as fallback for unknown tool call kinds
|
||||
* Minimal display: show description and outcome
|
||||
*/
|
||||
export const GenericToolCall: React.FC<BaseToolCallProps> = ({ toolCall }) => {
|
||||
const { kind, title, content, locations, toolCallId } = toolCall;
|
||||
const operationText = safeTitle(title);
|
||||
|
||||
// Group content by type
|
||||
const { textOutputs, errors } = groupContent(content);
|
||||
|
||||
// Error case: show operation + error in card layout
|
||||
if (errors.length > 0) {
|
||||
return (
|
||||
<ToolCallCard icon="🔧">
|
||||
<ToolCallRow label={kind}>
|
||||
<div>{operationText}</div>
|
||||
</ToolCallRow>
|
||||
<ToolCallRow label="Error">
|
||||
<div className="text-[#c74e39] font-medium">{errors.join('\n')}</div>
|
||||
</ToolCallRow>
|
||||
</ToolCallCard>
|
||||
);
|
||||
}
|
||||
|
||||
// Success with output: use card for long output, compact for short
|
||||
if (textOutputs.length > 0) {
|
||||
const output = textOutputs.join('\n');
|
||||
const isLong = output.length > 150;
|
||||
|
||||
if (isLong) {
|
||||
const truncatedOutput =
|
||||
output.length > 300 ? output.substring(0, 300) + '...' : output;
|
||||
|
||||
return (
|
||||
<ToolCallCard icon="🔧">
|
||||
<ToolCallRow label={kind}>
|
||||
<div>{operationText}</div>
|
||||
</ToolCallRow>
|
||||
<ToolCallRow label="Output">
|
||||
<div className="whitespace-pre-wrap font-mono text-[13px] opacity-90">
|
||||
{truncatedOutput}
|
||||
</div>
|
||||
</ToolCallRow>
|
||||
</ToolCallCard>
|
||||
);
|
||||
}
|
||||
|
||||
// Short output - compact format
|
||||
const statusFlag: 'success' | 'error' | 'warning' | 'loading' | 'default' =
|
||||
toolCall.status === 'in_progress' || toolCall.status === 'pending'
|
||||
? 'loading'
|
||||
: 'success';
|
||||
return (
|
||||
<ToolCallContainer
|
||||
label={kind}
|
||||
status={statusFlag}
|
||||
toolCallId={toolCallId}
|
||||
>
|
||||
{operationText || output}
|
||||
</ToolCallContainer>
|
||||
);
|
||||
}
|
||||
|
||||
// Success with files: show operation + file list in compact format
|
||||
if (locations && locations.length > 0) {
|
||||
const statusFlag: 'success' | 'error' | 'warning' | 'loading' | 'default' =
|
||||
toolCall.status === 'in_progress' || toolCall.status === 'pending'
|
||||
? 'loading'
|
||||
: 'success';
|
||||
return (
|
||||
<ToolCallContainer
|
||||
label={kind}
|
||||
status={statusFlag}
|
||||
toolCallId={toolCallId}
|
||||
>
|
||||
<LocationsList locations={locations} />
|
||||
</ToolCallContainer>
|
||||
);
|
||||
}
|
||||
|
||||
// No output - show just the operation
|
||||
if (operationText) {
|
||||
const statusFlag: 'success' | 'error' | 'warning' | 'loading' | 'default' =
|
||||
toolCall.status === 'in_progress' || toolCall.status === 'pending'
|
||||
? 'loading'
|
||||
: 'success';
|
||||
return (
|
||||
<ToolCallContainer
|
||||
label={kind}
|
||||
status={statusFlag}
|
||||
toolCallId={toolCallId}
|
||||
>
|
||||
{operationText}
|
||||
</ToolCallContainer>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
@@ -0,0 +1,177 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*
|
||||
* Read tool call component - specialized for file reading operations
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { useCallback, useEffect, useMemo } from 'react';
|
||||
import type { BaseToolCallProps } from '../shared/types.js';
|
||||
import {
|
||||
groupContent,
|
||||
mapToolStatusToContainerStatus,
|
||||
} from '../shared/utils.js';
|
||||
import { FileLink } from '../../../layout/FileLink.js';
|
||||
import { useVSCode } from '../../../../hooks/useVSCode.js';
|
||||
import { handleOpenDiff } from '../../../../utils/diffUtils.js';
|
||||
import type { ToolCallContainerProps } from '../shared/LayoutComponents.js';
|
||||
|
||||
export const ToolCallContainer: React.FC<ToolCallContainerProps> = ({
|
||||
label,
|
||||
status = 'success',
|
||||
children,
|
||||
toolCallId: _toolCallId,
|
||||
labelSuffix,
|
||||
className: _className,
|
||||
}) => (
|
||||
<div
|
||||
className={`ReadToolCall qwen-message message-item ${_className || ''} relative pl-[30px] py-2 select-text toolcall-container toolcall-status-${status}`}
|
||||
>
|
||||
<div className="toolcall-content-wrapper flex flex-col gap-1 min-w-0 max-w-full">
|
||||
<div className="flex items-baseline gap-1.5 relative min-w-0">
|
||||
<span className="text-[14px] leading-none font-bold text-[var(--app-primary-foreground)]">
|
||||
{label}
|
||||
</span>
|
||||
<span className="text-[11px] text-[var(--app-secondary-foreground)]">
|
||||
{labelSuffix}
|
||||
</span>
|
||||
</div>
|
||||
{children && (
|
||||
<div className="text-[var(--app-secondary-foreground)] py-1">
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
/**
|
||||
* Specialized component for Read tool calls
|
||||
* Optimized for displaying file reading operations
|
||||
* Shows: Read filename (no content preview)
|
||||
*/
|
||||
export const ReadToolCall: React.FC<BaseToolCallProps> = ({ toolCall }) => {
|
||||
const { content, locations, toolCallId } = toolCall;
|
||||
const vscode = useVSCode();
|
||||
|
||||
// Group content by type; memoize to avoid new array identities on every render
|
||||
const { errors, diffs } = useMemo(() => groupContent(content), [content]);
|
||||
|
||||
// Post a message to the extension host to open a VS Code diff tab
|
||||
const handleOpenDiffInternal = useCallback(
|
||||
(
|
||||
path: string | undefined,
|
||||
oldText: string | null | undefined,
|
||||
newText: string | undefined,
|
||||
) => {
|
||||
handleOpenDiff(vscode, path, oldText, newText);
|
||||
},
|
||||
[vscode],
|
||||
);
|
||||
|
||||
// Auto-open diff when a read call returns diff content.
|
||||
// Only trigger once per toolCallId so we don't spam as in-progress updates stream in.
|
||||
useEffect(() => {
|
||||
if (diffs.length > 0) {
|
||||
const firstDiff = diffs[0];
|
||||
const path = firstDiff.path || (locations && locations[0]?.path) || '';
|
||||
|
||||
if (
|
||||
path &&
|
||||
firstDiff.oldText !== undefined &&
|
||||
firstDiff.newText !== undefined
|
||||
) {
|
||||
const timer = setTimeout(() => {
|
||||
handleOpenDiffInternal(path, firstDiff.oldText, firstDiff.newText);
|
||||
}, 100);
|
||||
return () => timer && clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [toolCallId]);
|
||||
|
||||
// Compute container status based on toolCall.status (pending/in_progress -> loading)
|
||||
const containerStatus:
|
||||
| 'success'
|
||||
| 'error'
|
||||
| 'warning'
|
||||
| 'loading'
|
||||
| 'default' = mapToolStatusToContainerStatus(toolCall.status);
|
||||
|
||||
// Error case: show error
|
||||
if (errors.length > 0) {
|
||||
const path = locations?.[0]?.path || '';
|
||||
return (
|
||||
<ToolCallContainer
|
||||
label={'Read'}
|
||||
className="read-tool-call-error"
|
||||
status="error"
|
||||
toolCallId={toolCallId}
|
||||
labelSuffix={
|
||||
path ? (
|
||||
<FileLink
|
||||
path={path}
|
||||
showFullPath={false}
|
||||
className="text-xs font-mono text-[var(--app-secondary-foreground)] hover:underline"
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
{errors.join('\n')}
|
||||
</ToolCallContainer>
|
||||
);
|
||||
}
|
||||
|
||||
// Success case with diff: keep UI compact; VS Code diff is auto-opened above
|
||||
if (diffs.length > 0) {
|
||||
const path = diffs[0]?.path || locations?.[0]?.path || '';
|
||||
return (
|
||||
<ToolCallContainer
|
||||
label={'Read'}
|
||||
className="read-tool-call-success"
|
||||
status={containerStatus}
|
||||
toolCallId={toolCallId}
|
||||
labelSuffix={
|
||||
path ? (
|
||||
<FileLink
|
||||
path={path}
|
||||
showFullPath={false}
|
||||
className="text-xs font-mono text-[var(--app-secondary-foreground)] hover:underline"
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
{null}
|
||||
</ToolCallContainer>
|
||||
);
|
||||
}
|
||||
|
||||
// Success case: show which file was read with filename in label
|
||||
if (locations && locations.length > 0) {
|
||||
const path = locations[0].path;
|
||||
return (
|
||||
<ToolCallContainer
|
||||
label={'Read'}
|
||||
className="read-tool-call-success"
|
||||
status={containerStatus}
|
||||
toolCallId={toolCallId}
|
||||
labelSuffix={
|
||||
path ? (
|
||||
<FileLink
|
||||
path={path}
|
||||
showFullPath={false}
|
||||
className="text-xs font-mono text-[var(--app-secondary-foreground)] hover:underline"
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
{null}
|
||||
</ToolCallContainer>
|
||||
);
|
||||
}
|
||||
|
||||
// No file info, don't show
|
||||
return null;
|
||||
};
|
||||
@@ -0,0 +1,227 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*
|
||||
* Search tool call component - specialized for search operations
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import type { BaseToolCallProps } from '../shared/types.js';
|
||||
import { FileLink } from '../../../layout/FileLink.js';
|
||||
import {
|
||||
safeTitle,
|
||||
groupContent,
|
||||
mapToolStatusToContainerStatus,
|
||||
} from '../shared/utils.js';
|
||||
|
||||
/**
|
||||
* Specialized component for Search tool calls
|
||||
* Optimized for displaying search operations and results
|
||||
* Shows query + result count or file list
|
||||
*/
|
||||
const InlineContainer: React.FC<{
|
||||
status: 'success' | 'error' | 'warning' | 'loading' | 'default';
|
||||
labelSuffix?: string;
|
||||
children?: React.ReactNode;
|
||||
isFirst?: boolean;
|
||||
isLast?: boolean;
|
||||
}> = ({ status, labelSuffix, children, isFirst, isLast }) => {
|
||||
const beforeStatusClass = `toolcall-container toolcall-status-${status}`;
|
||||
const lineCropTop = isFirst ? 'top-[24px]' : 'top-0';
|
||||
const lineCropBottom = isLast
|
||||
? 'bottom-auto h-[calc(100%-24px)]'
|
||||
: 'bottom-0';
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
`qwen-message message-item relative pl-[30px] py-2 select-text ` +
|
||||
`before:absolute before:left-[8px] before:top-2 before:content-["\\25cf"] before:text-[10px] before:z-[1] ` +
|
||||
beforeStatusClass
|
||||
}
|
||||
>
|
||||
{/* timeline vertical line */}
|
||||
<div
|
||||
className={`absolute left-[12px] ${lineCropTop} ${lineCropBottom} w-px bg-[var(--app-primary-border-color)]`}
|
||||
aria-hidden
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-baseline gap-2 min-w-0">
|
||||
<span className="text-[14px] leading-none font-bold text-[var(--app-primary-foreground)]">
|
||||
Search
|
||||
</span>
|
||||
{labelSuffix ? (
|
||||
<span className="text-[11px] text-[var(--app-secondary-foreground)]">
|
||||
{labelSuffix}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
{children ? (
|
||||
<div className="mt-1 text-[var(--app-secondary-foreground)]">
|
||||
{children}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Local card layout for multi-result or error display
|
||||
const SearchCard: React.FC<{
|
||||
status: 'success' | 'error' | 'warning' | 'loading' | 'default';
|
||||
children: React.ReactNode;
|
||||
isFirst?: boolean;
|
||||
isLast?: boolean;
|
||||
}> = ({ status, children, isFirst, isLast }) => {
|
||||
const beforeStatusClass =
|
||||
status === 'success'
|
||||
? 'before:text-qwen-success'
|
||||
: status === 'error'
|
||||
? 'before:text-qwen-error'
|
||||
: status === 'warning'
|
||||
? 'before:text-qwen-warning'
|
||||
: 'before:text-qwen-loading before:opacity-70 before:animate-pulse-slow';
|
||||
const lineCropTop = isFirst ? 'top-[24px]' : 'top-0';
|
||||
const lineCropBottom = isLast
|
||||
? 'bottom-auto h-[calc(100%-24px)]'
|
||||
: 'bottom-0';
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
`qwen-message message-item relative pl-[30px] py-2 select-text ` +
|
||||
`before:absolute before:left-[8px] before:top-2 before:content-["\\25cf"] before:text-[10px] before:z-[1] ` +
|
||||
beforeStatusClass
|
||||
}
|
||||
>
|
||||
{/* timeline vertical line */}
|
||||
<div
|
||||
className={`absolute left-[12px] ${lineCropTop} ${lineCropBottom} w-px bg-[var(--app-primary-border-color)]`}
|
||||
aria-hidden
|
||||
/>
|
||||
<div className="bg-[var(--app-input-background)] border border-[var(--app-input-border)] rounded-medium p-large my-medium">
|
||||
<div className="flex flex-col gap-3 min-w-0">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const SearchRow: React.FC<{ label: string; children: React.ReactNode }> = ({
|
||||
label,
|
||||
children,
|
||||
}) => (
|
||||
<div className="grid grid-cols-[80px_1fr] gap-medium min-w-0">
|
||||
<div className="text-xs text-[var(--app-secondary-foreground)] font-medium pt-[2px]">
|
||||
{label}
|
||||
</div>
|
||||
<div className="text-[var(--app-primary-foreground)] min-w-0 break-words">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const LocationsListLocal: React.FC<{
|
||||
locations: Array<{ path: string; line?: number | null }>;
|
||||
}> = ({ locations }) => (
|
||||
<div className="flex flex-col gap-1 max-w-full">
|
||||
{locations.map((loc, idx) => (
|
||||
<FileLink key={idx} path={loc.path} line={loc.line} showFullPath={true} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
export const SearchToolCall: React.FC<BaseToolCallProps> = ({
|
||||
toolCall,
|
||||
isFirst,
|
||||
isLast,
|
||||
}) => {
|
||||
const { title, content, locations } = toolCall;
|
||||
const queryText = safeTitle(title);
|
||||
|
||||
// Group content by type
|
||||
const { errors, textOutputs } = groupContent(content);
|
||||
|
||||
// Error case: show search query + error in card layout
|
||||
if (errors.length > 0) {
|
||||
return (
|
||||
<SearchCard status="error" isFirst={isFirst} isLast={isLast}>
|
||||
<SearchRow label="Search">
|
||||
<div className="font-mono">{queryText}</div>
|
||||
</SearchRow>
|
||||
<SearchRow label="Error">
|
||||
<div className="text-qwen-error font-medium">{errors.join('\n')}</div>
|
||||
</SearchRow>
|
||||
</SearchCard>
|
||||
);
|
||||
}
|
||||
|
||||
// Success case with results: show search query + file list
|
||||
if (locations && locations.length > 0) {
|
||||
const containerStatus = mapToolStatusToContainerStatus(toolCall.status);
|
||||
// If multiple results, use card layout; otherwise use compact format
|
||||
if (locations.length > 1) {
|
||||
return (
|
||||
<SearchCard status={containerStatus} isFirst={isFirst} isLast={isLast}>
|
||||
<SearchRow label="Search">
|
||||
<div className="font-mono">{queryText}</div>
|
||||
</SearchRow>
|
||||
<SearchRow label={`Found (${locations.length})`}>
|
||||
<LocationsListLocal locations={locations} />
|
||||
</SearchRow>
|
||||
</SearchCard>
|
||||
);
|
||||
}
|
||||
// Single result - compact format
|
||||
return (
|
||||
<InlineContainer
|
||||
status={containerStatus}
|
||||
labelSuffix={`(${queryText})`}
|
||||
isFirst={isFirst}
|
||||
isLast={isLast}
|
||||
>
|
||||
<span className="mx-2 opacity-50">→</span>
|
||||
<LocationsListLocal locations={locations} />
|
||||
</InlineContainer>
|
||||
);
|
||||
}
|
||||
|
||||
// Show content text if available (e.g., "Listed 4 item(s).")
|
||||
if (textOutputs.length > 0) {
|
||||
const containerStatus = mapToolStatusToContainerStatus(toolCall.status);
|
||||
return (
|
||||
<InlineContainer
|
||||
status={containerStatus}
|
||||
labelSuffix={queryText ? `(${queryText})` : undefined}
|
||||
isFirst={isFirst}
|
||||
isLast={isLast}
|
||||
>
|
||||
<div className="flex flex-col">
|
||||
{textOutputs.map((text, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="inline-flex text-[var(--app-secondary-foreground)] text-[0.85em] opacity-70 mt-[2px] mb-[2px] flex-row items-start w-full gap-1"
|
||||
>
|
||||
<span className="flex-shrink-0 relative top-[-0.1em]">⎿</span>
|
||||
<span className="flex-shrink-0 w-full">{text}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</InlineContainer>
|
||||
);
|
||||
}
|
||||
|
||||
// No results - show query only
|
||||
if (queryText) {
|
||||
const containerStatus = mapToolStatusToContainerStatus(toolCall.status);
|
||||
return (
|
||||
<InlineContainer
|
||||
status={containerStatus}
|
||||
isFirst={isFirst}
|
||||
isLast={isLast}
|
||||
>
|
||||
<span className="font-mono">{queryText}</span>
|
||||
</InlineContainer>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*
|
||||
* Think tool call component - specialized for thinking/reasoning operations
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import type { BaseToolCallProps } from '../shared/types.js';
|
||||
import {
|
||||
ToolCallContainer,
|
||||
ToolCallCard,
|
||||
ToolCallRow,
|
||||
} from '../shared/LayoutComponents.js';
|
||||
import { groupContent } from '../shared/utils.js';
|
||||
|
||||
/**
|
||||
* Specialized component for Think tool calls
|
||||
* Optimized for displaying AI reasoning and thought processes
|
||||
* Minimal display: just show the thoughts (no context)
|
||||
*/
|
||||
export const ThinkToolCall: React.FC<BaseToolCallProps> = ({ toolCall }) => {
|
||||
const { content } = toolCall;
|
||||
|
||||
// Group content by type
|
||||
const { textOutputs, errors } = groupContent(content);
|
||||
|
||||
// Error case (rare for thinking)
|
||||
if (errors.length > 0) {
|
||||
return (
|
||||
<ToolCallContainer label="Thinking" status="error">
|
||||
{errors.join('\n')}
|
||||
</ToolCallContainer>
|
||||
);
|
||||
}
|
||||
|
||||
// Show thoughts - use card for long content, compact for short
|
||||
if (textOutputs.length > 0) {
|
||||
const thoughts = textOutputs.join('\n\n');
|
||||
const isLong = thoughts.length > 200;
|
||||
|
||||
if (isLong) {
|
||||
const truncatedThoughts =
|
||||
thoughts.length > 500 ? thoughts.substring(0, 500) + '...' : thoughts;
|
||||
|
||||
return (
|
||||
<ToolCallCard icon="💭">
|
||||
<ToolCallRow label="Thinking">
|
||||
<div className="italic opacity-90 leading-relaxed">
|
||||
{truncatedThoughts}
|
||||
</div>
|
||||
</ToolCallRow>
|
||||
</ToolCallCard>
|
||||
);
|
||||
}
|
||||
|
||||
// Short thoughts - compact format
|
||||
const status =
|
||||
toolCall.status === 'pending' || toolCall.status === 'in_progress'
|
||||
? 'loading'
|
||||
: 'default';
|
||||
return (
|
||||
<ToolCallContainer label="Thinking" status={status}>
|
||||
<span className="italic opacity-90">{thoughts}</span>
|
||||
</ToolCallContainer>
|
||||
);
|
||||
}
|
||||
|
||||
// Empty thoughts
|
||||
return null;
|
||||
};
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*
|
||||
* Main ToolCall component - uses factory pattern to route to specialized components
|
||||
*
|
||||
* This file serves as the public API for tool call rendering.
|
||||
* It re-exports the router and types from the toolcalls module.
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { ToolCallRouter } from './index.js';
|
||||
|
||||
// Re-export types from the toolcalls module for backward compatibility
|
||||
export type {
|
||||
ToolCallData,
|
||||
BaseToolCallProps as ToolCallProps,
|
||||
} from './shared/types.js';
|
||||
|
||||
// Re-export the content type for external use
|
||||
export type { ToolCallContent } from './shared/types.js';
|
||||
export const ToolCall: React.FC<{
|
||||
toolCall: import('./shared/types.js').ToolCallData;
|
||||
isFirst?: boolean;
|
||||
isLast?: boolean;
|
||||
}> = ({ toolCall, isFirst, isLast }) => (
|
||||
<ToolCallRouter toolCall={toolCall} isFirst={isFirst} isLast={isLast} />
|
||||
);
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
|
||||
export interface CheckboxDisplayProps {
|
||||
checked?: boolean;
|
||||
indeterminate?: boolean;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Display-only checkbox styled via Tailwind classes.
|
||||
* - Renders a custom-looking checkbox using appearance-none and pseudo-elements.
|
||||
* - Supports indeterminate (middle) state using the DOM property and a data- attribute.
|
||||
* - Intended for read-only display (disabled by default).
|
||||
*/
|
||||
export const CheckboxDisplay: React.FC<CheckboxDisplayProps> = ({
|
||||
checked = false,
|
||||
indeterminate = false,
|
||||
disabled = true,
|
||||
className = '',
|
||||
style,
|
||||
title,
|
||||
}) => {
|
||||
// Render as a span (not <input>) so we can draw a checkmark with CSS.
|
||||
// Pseudo-elements do not reliably render on <input> in Chromium (VS Code webviews),
|
||||
// which caused the missing icon. This version is font-free and uses borders.
|
||||
const showCheck = !!checked && !indeterminate;
|
||||
const showInProgress = !!indeterminate;
|
||||
|
||||
return (
|
||||
<span
|
||||
role="checkbox"
|
||||
aria-checked={indeterminate ? 'mixed' : !!checked}
|
||||
aria-disabled={disabled || undefined}
|
||||
title={title}
|
||||
style={style}
|
||||
className={[
|
||||
'q m-[2px] shrink-0 w-4 h-4 relative rounded-[2px] box-border',
|
||||
'border border-[var(--app-input-border)] bg-[var(--app-input-background)]',
|
||||
'inline-flex items-center justify-center',
|
||||
showCheck ? 'opacity-70' : '',
|
||||
className,
|
||||
].join(' ')}
|
||||
>
|
||||
{showCheck ? (
|
||||
<span
|
||||
aria-hidden
|
||||
className={[
|
||||
'absolute block',
|
||||
// Place the check slightly to the left/top so rotated arms stay inside the 16x16 box
|
||||
'left-[3px] top-[3px]',
|
||||
// 10x6 shape works well for a 16x16 checkbox
|
||||
'w-2.5 h-1.5',
|
||||
// Draw the L-corner and rotate to form a check
|
||||
'border-l-2 border-b-2',
|
||||
'border-[#74c991]',
|
||||
'-rotate-45',
|
||||
].join(' ')}
|
||||
/>
|
||||
) : null}
|
||||
{showInProgress ? (
|
||||
<span
|
||||
aria-hidden
|
||||
className={[
|
||||
'absolute inline-block',
|
||||
'left-1/2 top-[10px] -translate-x-1/2 -translate-y-1/2',
|
||||
// Use a literal star; no icon font needed
|
||||
'text-[16px] leading-none text-[#e1c08d] select-none',
|
||||
].join(' ')}
|
||||
>
|
||||
*
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,166 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*
|
||||
* UpdatedPlan tool call component - specialized for plan update operations
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import type { BaseToolCallProps } from '../shared/types.js';
|
||||
import type { ToolCallContainerProps } from '../shared/LayoutComponents.js';
|
||||
import { groupContent, safeTitle } from '../shared/utils.js';
|
||||
import { CheckboxDisplay } from './CheckboxDisplay.js';
|
||||
import type { PlanEntry } from '../../../../../types/chatTypes.js';
|
||||
|
||||
type EntryStatus = 'pending' | 'in_progress' | 'completed';
|
||||
|
||||
export const ToolCallContainer: React.FC<ToolCallContainerProps> = ({
|
||||
label,
|
||||
status = 'success',
|
||||
children,
|
||||
toolCallId: _toolCallId,
|
||||
labelSuffix,
|
||||
className: _className,
|
||||
}) => (
|
||||
<div
|
||||
className={`qwen-message message-item ${_className || ''} relative pl-[30px] py-2 select-text toolcall-container toolcall-status-${status}`}
|
||||
>
|
||||
<div className="UpdatedPlanToolCall toolcall-content-wrapper flex flex-col gap-2 min-w-0 max-w-full">
|
||||
<div className="flex items-baseline gap-1 relative min-w-0">
|
||||
<span className="text-[14px] leading-none font-bold text-[var(--app-primary-foreground)]">
|
||||
{label}
|
||||
</span>
|
||||
<span className="text-[11px] text-[var(--app-secondary-foreground)]">
|
||||
{labelSuffix}
|
||||
</span>
|
||||
</div>
|
||||
{children && (
|
||||
<div className="text-[var(--app-secondary-foreground)] py-1">
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const mapToolStatusToBullet = (
|
||||
status: import('../shared/types.js').ToolCallStatus,
|
||||
): 'success' | 'error' | 'warning' | 'loading' | 'default' => {
|
||||
switch (status) {
|
||||
case 'completed':
|
||||
return 'success';
|
||||
case 'failed':
|
||||
return 'error';
|
||||
case 'in_progress':
|
||||
return 'warning';
|
||||
case 'pending':
|
||||
return 'loading';
|
||||
default:
|
||||
return 'default';
|
||||
}
|
||||
};
|
||||
|
||||
// Parse plan entries with - [ ] / - [x] from text as much as possible
|
||||
const parsePlanEntries = (textOutputs: string[]): PlanEntry[] => {
|
||||
const text = textOutputs.join('\n');
|
||||
const lines = text.split(/\r?\n/);
|
||||
const entries: PlanEntry[] = [];
|
||||
|
||||
// Accept [ ], [x]/[X] and in-progress markers [-] or [*]
|
||||
const todoRe = /^(?:\s*(?:[-*]|\d+[.)])\s*)?\[( |x|X|-|\*)\]\s+(.*)$/;
|
||||
for (const line of lines) {
|
||||
const m = line.match(todoRe);
|
||||
if (m) {
|
||||
const mark = m[1];
|
||||
const title = m[2].trim();
|
||||
const status: EntryStatus =
|
||||
mark === 'x' || mark === 'X'
|
||||
? 'completed'
|
||||
: mark === '-' || mark === '*'
|
||||
? 'in_progress'
|
||||
: 'pending';
|
||||
if (title) {
|
||||
entries.push({ content: title, status });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If no match is found, fall back to treating non-empty lines as pending items
|
||||
if (entries.length === 0) {
|
||||
for (const line of lines) {
|
||||
const title = line.trim();
|
||||
if (title) {
|
||||
entries.push({ content: title, status: 'pending' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return entries;
|
||||
};
|
||||
|
||||
/**
|
||||
* Specialized component for UpdatedPlan tool calls
|
||||
* Optimized for displaying plan update operations
|
||||
*/
|
||||
export const UpdatedPlanToolCall: React.FC<BaseToolCallProps> = ({
|
||||
toolCall,
|
||||
}) => {
|
||||
const { content, status } = toolCall;
|
||||
const { errors, textOutputs } = groupContent(content);
|
||||
|
||||
// Error-first display
|
||||
if (errors.length > 0) {
|
||||
return (
|
||||
<ToolCallContainer label="Updated Plan" status="error">
|
||||
{errors.join('\n')}
|
||||
</ToolCallContainer>
|
||||
);
|
||||
}
|
||||
|
||||
const entries = parsePlanEntries(textOutputs);
|
||||
|
||||
const label = safeTitle(toolCall.title) || 'Updated Plan';
|
||||
|
||||
return (
|
||||
<ToolCallContainer
|
||||
label={label}
|
||||
status={mapToolStatusToBullet(status)}
|
||||
className="update-plan-toolcall"
|
||||
>
|
||||
<ul className="Fr list-none p-0 m-0 flex flex-col gap-1">
|
||||
{entries.map((entry, idx) => {
|
||||
const isDone = entry.status === 'completed';
|
||||
const isIndeterminate = entry.status === 'in_progress';
|
||||
return (
|
||||
<li
|
||||
key={idx}
|
||||
className={[
|
||||
'Hr flex items-start gap-2 p-0 rounded text-[var(--app-primary-foreground)]',
|
||||
isDone ? 'fo opacity-70' : '',
|
||||
].join(' ')}
|
||||
>
|
||||
<label className="flex items-start gap-2">
|
||||
<CheckboxDisplay
|
||||
checked={isDone}
|
||||
indeterminate={isIndeterminate}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div
|
||||
className={[
|
||||
'vo flex-1 text-xs leading-[1.5] text-[var(--app-primary-foreground)]',
|
||||
isDone
|
||||
? 'line-through text-[var(--app-secondary-foreground)] opacity-70'
|
||||
: 'opacity-85',
|
||||
].join(' ')}
|
||||
>
|
||||
{entry.content}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</ToolCallContainer>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*
|
||||
* Write tool call component - specialized for file writing operations
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import type { BaseToolCallProps } from '../shared/types.js';
|
||||
import { ToolCallContainer } from '../shared/LayoutComponents.js';
|
||||
import {
|
||||
groupContent,
|
||||
mapToolStatusToContainerStatus,
|
||||
} from '../shared/utils.js';
|
||||
import { FileLink } from '../../../layout/FileLink.js';
|
||||
|
||||
/**
|
||||
* Specialized component for Write tool calls
|
||||
* Shows: Write filename + error message + content preview
|
||||
*/
|
||||
export const WriteToolCall: React.FC<BaseToolCallProps> = ({ toolCall }) => {
|
||||
const { content, locations, rawInput, toolCallId } = toolCall;
|
||||
|
||||
// Group content by type
|
||||
const { errors, textOutputs } = groupContent(content);
|
||||
|
||||
// Extract filename from path
|
||||
// const getFileName = (path: string): string => path.split('/').pop() || path;
|
||||
|
||||
// Extract content to write from rawInput
|
||||
let writeContent = '';
|
||||
if (rawInput && typeof rawInput === 'object') {
|
||||
const inputObj = rawInput as { content?: string };
|
||||
writeContent = inputObj.content || '';
|
||||
} else if (typeof rawInput === 'string') {
|
||||
writeContent = rawInput;
|
||||
}
|
||||
|
||||
// Error case: show filename + error message + content preview
|
||||
if (errors.length > 0) {
|
||||
const path = locations?.[0]?.path || '';
|
||||
const errorMessage = errors.join('\n');
|
||||
|
||||
// Truncate content preview
|
||||
const truncatedContent =
|
||||
writeContent.length > 200
|
||||
? writeContent.substring(0, 200) + '...'
|
||||
: writeContent;
|
||||
|
||||
return (
|
||||
<ToolCallContainer
|
||||
label={'Write'}
|
||||
status="error"
|
||||
toolCallId={toolCallId}
|
||||
labelSuffix={
|
||||
path ? (
|
||||
<FileLink
|
||||
path={path}
|
||||
showFullPath={false}
|
||||
className="text-xs font-mono text-[var(--app-secondary-foreground)] hover:underline"
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
<div className="inline-flex text-[var(--app-secondary-foreground)] text-[0.85em] opacity-70 mt-[2px] mb-[2px] flex-row items-start w-full gap-1">
|
||||
<span className="flex-shrink-0 relative top-[-0.1em]">⎿</span>
|
||||
<span className="flex-shrink-0 w-full">{errorMessage}</span>
|
||||
</div>
|
||||
{truncatedContent && (
|
||||
<div className="bg-[var(--app-input-background)] border border-[var(--app-input-border)] rounded-md p-3 mt-1">
|
||||
<pre className="font-mono text-[13px] whitespace-pre-wrap break-words text-[var(--app-primary-foreground)] opacity-90">
|
||||
{truncatedContent}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</ToolCallContainer>
|
||||
);
|
||||
}
|
||||
|
||||
// Success case: show filename + line count
|
||||
if (locations && locations.length > 0) {
|
||||
const path = locations[0].path;
|
||||
const lineCount = writeContent.split('\n').length;
|
||||
const containerStatus = mapToolStatusToContainerStatus(toolCall.status);
|
||||
return (
|
||||
<ToolCallContainer
|
||||
label={'Created'}
|
||||
status={containerStatus}
|
||||
toolCallId={toolCallId}
|
||||
labelSuffix={
|
||||
path ? (
|
||||
<FileLink
|
||||
path={path}
|
||||
showFullPath={false}
|
||||
className="text-xs font-mono text-[var(--app-secondary-foreground)] hover:underline"
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
<div className="inline-flex text-[var(--app-secondary-foreground)] text-[0.85em] opacity-70 flex-row items-start w-full gap-1 flex items-center">
|
||||
<span className="flex-shrink-0 relative top-[-0.1em]">⎿</span>
|
||||
<span className="flex-shrink-0 w-full">{lineCount} lines</span>
|
||||
</div>
|
||||
</ToolCallContainer>
|
||||
);
|
||||
}
|
||||
|
||||
// Fallback: show generic success
|
||||
if (textOutputs.length > 0) {
|
||||
const containerStatus = mapToolStatusToContainerStatus(toolCall.status);
|
||||
return (
|
||||
<ToolCallContainer
|
||||
label="Write"
|
||||
status={containerStatus}
|
||||
toolCallId={toolCallId}
|
||||
>
|
||||
{textOutputs.join('\n')}
|
||||
</ToolCallContainer>
|
||||
);
|
||||
}
|
||||
|
||||
// No output, don't show anything
|
||||
return null;
|
||||
};
|
||||
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*
|
||||
* Tool call component factory - routes to specialized components by kind
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import type { BaseToolCallProps } from './shared/types.js';
|
||||
import { shouldShowToolCall } from './shared/utils.js';
|
||||
import { GenericToolCall } from './GenericToolCall.js';
|
||||
import { ReadToolCall } from './Read/ReadToolCall.js';
|
||||
import { WriteToolCall } from './Write/WriteToolCall.js';
|
||||
import { EditToolCall } from './Edit/EditToolCall.js';
|
||||
import { ExecuteToolCall as BashExecuteToolCall } from './Bash/Bash.js';
|
||||
import { ExecuteToolCall } from './Execute/Execute.js';
|
||||
import { UpdatedPlanToolCall } from './UpdatedPlan/UpdatedPlanToolCall.js';
|
||||
import { SearchToolCall } from './Search/SearchToolCall.js';
|
||||
import { ThinkToolCall } from './Think/ThinkToolCall.js';
|
||||
|
||||
/**
|
||||
* Factory function that returns the appropriate tool call component based on kind
|
||||
*/
|
||||
export const getToolCallComponent = (
|
||||
kind: string,
|
||||
): React.FC<BaseToolCallProps> => {
|
||||
const normalizedKind = kind.toLowerCase();
|
||||
|
||||
// Route to specialized components
|
||||
switch (normalizedKind) {
|
||||
case 'read':
|
||||
return ReadToolCall;
|
||||
|
||||
case 'write':
|
||||
return WriteToolCall;
|
||||
|
||||
case 'edit':
|
||||
return EditToolCall;
|
||||
|
||||
case 'execute':
|
||||
return ExecuteToolCall;
|
||||
|
||||
case 'bash':
|
||||
case 'command':
|
||||
return BashExecuteToolCall;
|
||||
|
||||
case 'updated_plan':
|
||||
case 'updatedplan':
|
||||
case 'todo_write':
|
||||
case 'update_todos':
|
||||
case 'todowrite':
|
||||
return UpdatedPlanToolCall;
|
||||
|
||||
case 'search':
|
||||
case 'grep':
|
||||
case 'glob':
|
||||
case 'find':
|
||||
return SearchToolCall;
|
||||
|
||||
case 'think':
|
||||
case 'thinking':
|
||||
return ThinkToolCall;
|
||||
|
||||
default:
|
||||
// Fallback to generic component
|
||||
return GenericToolCall;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Main tool call component that routes to specialized implementations
|
||||
*/
|
||||
export const ToolCallRouter: React.FC<
|
||||
BaseToolCallProps & { isFirst?: boolean; isLast?: boolean }
|
||||
> = ({ toolCall, isFirst, isLast }) => {
|
||||
// Check if we should show this tool call (hide internal ones)
|
||||
if (!shouldShowToolCall(toolCall.kind)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get the appropriate component for this kind
|
||||
const Component = getToolCallComponent(toolCall.kind);
|
||||
|
||||
// Render the specialized component
|
||||
return <Component toolCall={toolCall} isFirst={isFirst} isLast={isLast} />;
|
||||
};
|
||||
|
||||
// Re-export types for convenience
|
||||
export type { BaseToolCallProps, ToolCallData } from './shared/types.js';
|
||||
@@ -0,0 +1,204 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*
|
||||
* LayoutComponents.css - Tool call layout styles with timeline support
|
||||
*/
|
||||
|
||||
/* ToolCallContainer with timeline support */
|
||||
.toolcall-container {
|
||||
position: relative;
|
||||
padding-left: 30px;
|
||||
padding-top: 8px;
|
||||
padding-bottom: 8px;
|
||||
user-select: text;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
/* Default timeline connector line */
|
||||
.toolcall-container::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 12px;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 1px;
|
||||
background-color: var(--app-primary-border-color);
|
||||
}
|
||||
|
||||
/* First item: connector starts from status point position */
|
||||
.toolcall-container:first-child::after {
|
||||
top: 24px;
|
||||
}
|
||||
|
||||
/* Last item: connector shows only upper part */
|
||||
.toolcall-container:last-child::after {
|
||||
height: calc(100% - 24px);
|
||||
top: 0;
|
||||
bottom: auto;
|
||||
}
|
||||
|
||||
/* Status-specific styles using ::before pseudo-element for bullet points */
|
||||
.toolcall-container.toolcall-status-default::before {
|
||||
content: '\25cf';
|
||||
position: absolute;
|
||||
left: 8px;
|
||||
padding-top: 2px;
|
||||
font-size: 10px;
|
||||
color: var(--app-secondary-foreground);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.toolcall-container.toolcall-status-success::before {
|
||||
content: '\25cf';
|
||||
position: absolute;
|
||||
left: 8px;
|
||||
font-size: 10px;
|
||||
color: #74c991;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.toolcall-container.toolcall-status-error::before {
|
||||
content: '\25cf';
|
||||
position: absolute;
|
||||
left: 8px;
|
||||
font-size: 10px;
|
||||
color: #c74e39;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.toolcall-container.toolcall-status-warning::before {
|
||||
content: '\25cf';
|
||||
position: absolute;
|
||||
left: 8px;
|
||||
padding-top: 2px;
|
||||
font-size: 10px;
|
||||
color: #e1c08d;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.toolcall-container.toolcall-status-loading::before {
|
||||
content: '\25cf';
|
||||
position: absolute;
|
||||
left: 8px;
|
||||
padding-top: 2px;
|
||||
font-size: 10px;
|
||||
color: var(--app-secondary-foreground);
|
||||
background-color: var(--app-secondary-background);
|
||||
animation: toolcallPulse 1s linear infinite;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* Loading animation */
|
||||
@keyframes toolcallPulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
/* Content wrapper */
|
||||
.toolcall-content-wrapper {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
/* Legacy card styles */
|
||||
.toolcall-card {
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: var(--spacing-medium);
|
||||
background: var(--app-input-background);
|
||||
border: 1px solid var(--app-input-border);
|
||||
border-radius: var(--border-radius-medium);
|
||||
padding: var(--spacing-large);
|
||||
margin: var(--spacing-medium) 0;
|
||||
align-items: start;
|
||||
animation: fadeIn 0.2s ease-in;
|
||||
}
|
||||
|
||||
/* Legacy row styles */
|
||||
.toolcall-row {
|
||||
grid-template-columns: 80px 1fr;
|
||||
gap: var(--spacing-medium);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.toolcall-row-label {
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--app-secondary-foreground);
|
||||
font-weight: 500;
|
||||
padding-top: 2px;
|
||||
}
|
||||
|
||||
.toolcall-row-content {
|
||||
color: var(--app-primary-foreground);
|
||||
min-width: 0;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
/* Locations list */
|
||||
.toolcall-locations-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
/* ToolCall header with loading indicator */
|
||||
.toolcall-header {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.toolcall-header::before {
|
||||
content: '\25cf';
|
||||
position: absolute;
|
||||
left: -22px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
font-size: 10px;
|
||||
line-height: 1;
|
||||
z-index: 1;
|
||||
color: #e1c08d;
|
||||
animation: toolcallHeaderPulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* Loading animation for toolcall header */
|
||||
@keyframes toolcallHeaderPulse {
|
||||
0%, 100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
/* In-progress toolcall specific styles */
|
||||
.in-progress-toolcall .toolcall-content-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.in-progress-toolcall .toolcall-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2;
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.in-progress-toolcall .toolcall-content-text {
|
||||
word-break: break-word;
|
||||
white-space: pre-wrap;
|
||||
width: 100%;
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*
|
||||
* Shared layout components for tool call UI
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { FileLink } from '../../../layout/FileLink.js';
|
||||
import './LayoutComponents.css';
|
||||
|
||||
/**
|
||||
* Props for ToolCallContainer
|
||||
*/
|
||||
export interface ToolCallContainerProps {
|
||||
/** Operation label (e.g., "Read", "Write", "Search") */
|
||||
label: string;
|
||||
/** Status for bullet color: 'success' | 'error' | 'warning' | 'loading' | 'default' */
|
||||
status?: 'success' | 'error' | 'warning' | 'loading' | 'default';
|
||||
/** Main content to display */
|
||||
children: React.ReactNode;
|
||||
/** Tool call ID for debugging */
|
||||
toolCallId?: string;
|
||||
/** Optional trailing content rendered next to label (e.g., clickable filename) */
|
||||
labelSuffix?: React.ReactNode;
|
||||
/** Optional custom class name */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const ToolCallContainer: React.FC<ToolCallContainerProps> = ({
|
||||
label,
|
||||
status = 'success',
|
||||
children,
|
||||
toolCallId: _toolCallId,
|
||||
labelSuffix,
|
||||
className: _className,
|
||||
}) => (
|
||||
<div
|
||||
className={`qwen-message message-item ${_className || ''} relative pl-[30px] py-2 select-text toolcall-container toolcall-status-${status}`}
|
||||
>
|
||||
<div className="toolcall-content-wrapper flex flex-col gap-2 min-w-0 max-w-full">
|
||||
<div className="flex items-baseline gap-1 relative min-w-0">
|
||||
<span className="text-[14px] leading-none font-bold text-[var(--app-primary-foreground)]">
|
||||
{label}
|
||||
</span>
|
||||
<span className="text-[11px] text-[var(--app-secondary-foreground)]">
|
||||
{labelSuffix}
|
||||
</span>
|
||||
</div>
|
||||
{children && (
|
||||
<div className="text-[var(--app-secondary-foreground)] py-1">
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
interface ToolCallCardProps {
|
||||
icon: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy card wrapper - kept for backward compatibility with complex layouts like diffs
|
||||
*/
|
||||
export const ToolCallCard: React.FC<ToolCallCardProps> = ({
|
||||
icon: _icon,
|
||||
children,
|
||||
}) => (
|
||||
<div className="grid grid-cols-[auto_1fr] gap-medium bg-[var(--app-input-background)] border border-[var(--app-input-border)] rounded-medium p-large my-medium items-start animate-[fadeIn_0.2s_ease-in] toolcall-card">
|
||||
<div className="flex flex-col gap-medium min-w-0">{children}</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
interface ToolCallRowProps {
|
||||
label: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* A single row in the tool call grid (legacy - for complex layouts)
|
||||
*/
|
||||
export const ToolCallRow: React.FC<ToolCallRowProps> = ({
|
||||
label,
|
||||
children,
|
||||
}) => (
|
||||
<div className="grid grid-cols-[80px_1fr] gap-medium min-w-0">
|
||||
<div className="text-xs text-[var(--app-secondary-foreground)] font-medium pt-[2px]">
|
||||
{label}
|
||||
</div>
|
||||
<div className="text-[var(--app-primary-foreground)] min-w-0 break-words">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
/**
|
||||
* Props for StatusIndicator
|
||||
*/
|
||||
interface StatusIndicatorProps {
|
||||
status: 'pending' | 'in_progress' | 'completed' | 'failed';
|
||||
text: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get status color class
|
||||
*/
|
||||
const getStatusColorClass = (
|
||||
status: 'pending' | 'in_progress' | 'completed' | 'failed',
|
||||
): string => {
|
||||
switch (status) {
|
||||
case 'pending':
|
||||
return 'bg-[#ffc107]';
|
||||
case 'in_progress':
|
||||
return 'bg-[#2196f3]';
|
||||
case 'completed':
|
||||
return 'bg-[#4caf50]';
|
||||
case 'failed':
|
||||
return 'bg-[#f44336]';
|
||||
default:
|
||||
return 'bg-gray-500';
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Status indicator with colored dot
|
||||
*/
|
||||
export const StatusIndicator: React.FC<StatusIndicatorProps> = ({
|
||||
status,
|
||||
text,
|
||||
}) => (
|
||||
<div className="inline-block font-medium relative" title={status}>
|
||||
<span
|
||||
className={`inline-block w-1.5 h-1.5 rounded-full mr-1.5 align-middle ${getStatusColorClass(status)}`}
|
||||
/>
|
||||
{text}
|
||||
</div>
|
||||
);
|
||||
|
||||
interface CodeBlockProps {
|
||||
children: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Code block for displaying formatted code or output
|
||||
*/
|
||||
export const CodeBlock: React.FC<CodeBlockProps> = ({ children }) => (
|
||||
<pre className="font-mono text-[var(--app-monospace-font-size)] bg-[var(--app-primary-background)] border border-[var(--app-input-border)] rounded-small p-medium overflow-x-auto mt-1 whitespace-pre-wrap break-words max-h-[300px] overflow-y-auto">
|
||||
{children}
|
||||
</pre>
|
||||
);
|
||||
|
||||
/**
|
||||
* Props for LocationsList
|
||||
*/
|
||||
interface LocationsListProps {
|
||||
locations: Array<{
|
||||
path: string;
|
||||
line?: number | null;
|
||||
}>;
|
||||
}
|
||||
|
||||
/**
|
||||
* List of file locations with clickable links
|
||||
*/
|
||||
export const LocationsList: React.FC<LocationsListProps> = ({ locations }) => (
|
||||
<div className="toolcall-locations-list flex flex-col gap-1 max-w-full">
|
||||
{locations.map((loc, idx) => (
|
||||
<FileLink key={idx} path={loc.path} line={loc.line} showFullPath={true} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*
|
||||
* Shared types for tool call components
|
||||
*/
|
||||
|
||||
/**
|
||||
* Tool call content types
|
||||
*/
|
||||
export interface ToolCallContent {
|
||||
type: 'content' | 'diff';
|
||||
// For content type
|
||||
content?: {
|
||||
type: string;
|
||||
text?: string;
|
||||
error?: unknown;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
// For diff type
|
||||
path?: string;
|
||||
oldText?: string | null;
|
||||
newText?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tool call location type
|
||||
*/
|
||||
export interface ToolCallLocation {
|
||||
path: string;
|
||||
line?: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tool call status type
|
||||
*/
|
||||
export type ToolCallStatus = 'pending' | 'in_progress' | 'completed' | 'failed';
|
||||
|
||||
/**
|
||||
* Base tool call data interface
|
||||
*/
|
||||
export interface ToolCallData {
|
||||
toolCallId: string;
|
||||
kind: string;
|
||||
title: string | object;
|
||||
status: ToolCallStatus;
|
||||
rawInput?: string | object;
|
||||
content?: ToolCallContent[];
|
||||
locations?: ToolCallLocation[];
|
||||
timestamp?: number; // Add a timestamp field for message sorting
|
||||
}
|
||||
|
||||
/**
|
||||
* Base props for all tool call components
|
||||
*/
|
||||
export interface BaseToolCallProps {
|
||||
toolCall: ToolCallData;
|
||||
// Optional timeline flags for rendering connector line cropping
|
||||
isFirst?: boolean;
|
||||
isLast?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Grouped content structure for rendering
|
||||
*/
|
||||
export interface GroupedContent {
|
||||
textOutputs: string[];
|
||||
errors: string[];
|
||||
diffs: ToolCallContent[];
|
||||
otherData: unknown[];
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*
|
||||
* Shared utility functions for tool call components
|
||||
*/
|
||||
|
||||
import type {
|
||||
ToolCallContent,
|
||||
GroupedContent,
|
||||
ToolCallStatus,
|
||||
} from './types.js';
|
||||
|
||||
/**
|
||||
* Format any value to a string for display
|
||||
*/
|
||||
export const formatValue = (value: unknown): string => {
|
||||
if (value === null || value === undefined) {
|
||||
return '';
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
// TODO: Trying to take out the Output part from the string
|
||||
try {
|
||||
value = (JSON.parse(value) as { output?: unknown }).output ?? value;
|
||||
} catch (_error) {
|
||||
// ignore JSON parse errors
|
||||
}
|
||||
return value as string;
|
||||
}
|
||||
// Handle Error objects specially
|
||||
if (value instanceof Error) {
|
||||
return value.message || value.toString();
|
||||
}
|
||||
// Handle error-like objects with message property
|
||||
if (typeof value === 'object' && value !== null && 'message' in value) {
|
||||
const errorObj = value as { message?: string; stack?: string };
|
||||
return errorObj.message || String(value);
|
||||
}
|
||||
if (typeof value === 'object') {
|
||||
try {
|
||||
return JSON.stringify(value, null, 2);
|
||||
} catch (_e) {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
return String(value);
|
||||
};
|
||||
|
||||
/**
|
||||
* Safely convert title to string, handling object types
|
||||
* Returns empty string if no meaningful title
|
||||
*/
|
||||
export const safeTitle = (title: unknown): string => {
|
||||
if (typeof title === 'string' && title.trim()) {
|
||||
return title;
|
||||
}
|
||||
if (title && typeof title === 'object') {
|
||||
return JSON.stringify(title);
|
||||
}
|
||||
return '';
|
||||
};
|
||||
|
||||
/**
|
||||
* Get icon emoji for a given tool kind
|
||||
*/
|
||||
export const getKindIcon = (kind: string): string => {
|
||||
const kindMap: Record<string, string> = {
|
||||
edit: '✏️',
|
||||
write: '✏️',
|
||||
read: '📖',
|
||||
execute: '⚡',
|
||||
fetch: '🌐',
|
||||
delete: '🗑️',
|
||||
move: '📦',
|
||||
search: '🔍',
|
||||
think: '💭',
|
||||
diff: '📝',
|
||||
};
|
||||
return kindMap[kind.toLowerCase()] || '🔧';
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if a tool call should be displayed
|
||||
* Hides internal tool calls
|
||||
*/
|
||||
export const shouldShowToolCall = (kind: string): boolean =>
|
||||
!kind.includes('internal');
|
||||
|
||||
/**
|
||||
* Check if a tool call has actual output to display
|
||||
* Returns false for tool calls that completed successfully but have no visible output
|
||||
*/
|
||||
export const hasToolCallOutput = (
|
||||
toolCall: import('./types.js').ToolCallData,
|
||||
): boolean => {
|
||||
// Always show failed tool calls (even without content)
|
||||
if (toolCall.status === 'failed') {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Always show execute/bash/command tool calls (they show the command in title)
|
||||
const kind = toolCall.kind.toLowerCase();
|
||||
if (kind === 'execute' || kind === 'bash' || kind === 'command') {
|
||||
// But only if they have a title
|
||||
if (
|
||||
toolCall.title &&
|
||||
typeof toolCall.title === 'string' &&
|
||||
toolCall.title.trim()
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Show if there are locations (file paths)
|
||||
if (toolCall.locations && toolCall.locations.length > 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Show if there is content
|
||||
if (toolCall.content && toolCall.content.length > 0) {
|
||||
const grouped = groupContent(toolCall.content);
|
||||
// Has any meaningful content?
|
||||
if (
|
||||
grouped.textOutputs.length > 0 ||
|
||||
grouped.errors.length > 0 ||
|
||||
grouped.diffs.length > 0 ||
|
||||
grouped.otherData.length > 0
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Show if there's a meaningful title for generic tool calls
|
||||
if (
|
||||
toolCall.title &&
|
||||
typeof toolCall.title === 'string' &&
|
||||
toolCall.title.trim()
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// No output, don't show
|
||||
return false;
|
||||
};
|
||||
|
||||
/**
|
||||
* Group tool call content by type to avoid duplicate labels
|
||||
*/
|
||||
export const groupContent = (content?: ToolCallContent[]): GroupedContent => {
|
||||
const textOutputs: string[] = [];
|
||||
const errors: string[] = [];
|
||||
const diffs: ToolCallContent[] = [];
|
||||
const otherData: unknown[] = [];
|
||||
|
||||
content?.forEach((item) => {
|
||||
if (item.type === 'diff') {
|
||||
diffs.push(item);
|
||||
} else if (item.content) {
|
||||
const contentObj = item.content;
|
||||
|
||||
// Handle error content
|
||||
if (contentObj.type === 'error' || 'error' in contentObj) {
|
||||
// Try to extract meaningful error message
|
||||
let errorMsg = '';
|
||||
|
||||
// Check if error is a string
|
||||
if (typeof contentObj.error === 'string') {
|
||||
errorMsg = contentObj.error;
|
||||
}
|
||||
// Check if error has a message property
|
||||
else if (
|
||||
contentObj.error &&
|
||||
typeof contentObj.error === 'object' &&
|
||||
'message' in contentObj.error
|
||||
) {
|
||||
errorMsg = (contentObj.error as { message: string }).message;
|
||||
}
|
||||
// Try text field
|
||||
else if (contentObj.text) {
|
||||
errorMsg = formatValue(contentObj.text);
|
||||
}
|
||||
// Format the error object itself
|
||||
else if (contentObj.error) {
|
||||
errorMsg = formatValue(contentObj.error);
|
||||
}
|
||||
// Fallback
|
||||
else {
|
||||
errorMsg = 'An error occurred';
|
||||
}
|
||||
|
||||
errors.push(errorMsg);
|
||||
}
|
||||
// Handle text content
|
||||
else if (contentObj.text) {
|
||||
textOutputs.push(formatValue(contentObj.text));
|
||||
}
|
||||
// Handle other content
|
||||
else {
|
||||
otherData.push(contentObj);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return { textOutputs, errors, diffs, otherData };
|
||||
};
|
||||
|
||||
/**
|
||||
* Map a tool call status to a ToolCallContainer status (bullet color)
|
||||
* - pending/in_progress -> loading
|
||||
* - completed -> success
|
||||
* - failed -> error
|
||||
* - default fallback
|
||||
*/
|
||||
export const mapToolStatusToContainerStatus = (
|
||||
status: ToolCallStatus,
|
||||
): 'success' | 'error' | 'warning' | 'loading' | 'default' => {
|
||||
switch (status) {
|
||||
case 'pending':
|
||||
case 'in_progress':
|
||||
return 'loading';
|
||||
case 'failed':
|
||||
return 'error';
|
||||
case 'completed':
|
||||
return 'success';
|
||||
default:
|
||||
return 'default';
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import * as vscode from 'vscode';
|
||||
import { BaseMessageHandler } from './BaseMessageHandler.js';
|
||||
|
||||
/**
|
||||
* Auth message handler
|
||||
* Handles all authentication-related messages
|
||||
*/
|
||||
export class AuthMessageHandler extends BaseMessageHandler {
|
||||
private loginHandler: (() => Promise<void>) | null = null;
|
||||
|
||||
canHandle(messageType: string): boolean {
|
||||
return ['login'].includes(messageType);
|
||||
}
|
||||
|
||||
async handle(message: { type: string; data?: unknown }): Promise<void> {
|
||||
switch (message.type) {
|
||||
case 'login':
|
||||
await this.handleLogin();
|
||||
break;
|
||||
|
||||
default:
|
||||
console.warn(
|
||||
'[AuthMessageHandler] Unknown message type:',
|
||||
message.type,
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set login handler
|
||||
*/
|
||||
setLoginHandler(handler: () => Promise<void>): void {
|
||||
this.loginHandler = handler;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle login request
|
||||
*/
|
||||
private async handleLogin(): Promise<void> {
|
||||
try {
|
||||
console.log('[AuthMessageHandler] Login requested');
|
||||
console.log(
|
||||
'[AuthMessageHandler] Login handler available:',
|
||||
!!this.loginHandler,
|
||||
);
|
||||
|
||||
// Direct login without additional confirmation
|
||||
if (this.loginHandler) {
|
||||
console.log('[AuthMessageHandler] Calling login handler');
|
||||
await this.loginHandler();
|
||||
console.log(
|
||||
'[AuthMessageHandler] Login handler completed successfully',
|
||||
);
|
||||
} else {
|
||||
console.log('[AuthMessageHandler] Using fallback login method');
|
||||
// Fallback: show message and use command
|
||||
vscode.window.showInformationMessage(
|
||||
'Please wait while we connect to Qwen Code...',
|
||||
);
|
||||
await vscode.commands.executeCommand('qwen-code.login');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[AuthMessageHandler] Login failed:', error);
|
||||
console.error(
|
||||
'[AuthMessageHandler] Error stack:',
|
||||
error instanceof Error ? error.stack : 'N/A',
|
||||
);
|
||||
this.sendToWebView({
|
||||
type: 'loginError',
|
||||
data: {
|
||||
message: `Login failed: ${error instanceof Error ? error.message : String(error)}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { QwenAgentManager } from '../../services/qwenAgentManager.js';
|
||||
import type { ConversationStore } from '../../services/conversationStore.js';
|
||||
|
||||
/**
|
||||
* Base message handler interface
|
||||
* All sub-handlers should implement this interface
|
||||
*/
|
||||
export interface IMessageHandler {
|
||||
/**
|
||||
* Handle message
|
||||
* @param message - Message object
|
||||
* @returns Promise<void>
|
||||
*/
|
||||
handle(message: { type: string; data?: unknown }): Promise<void>;
|
||||
|
||||
/**
|
||||
* Check if this handler can handle the message type
|
||||
* @param messageType - Message type
|
||||
* @returns boolean
|
||||
*/
|
||||
canHandle(messageType: string): boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Base message handler class
|
||||
* Provides common dependency injection and helper methods
|
||||
*/
|
||||
export abstract class BaseMessageHandler implements IMessageHandler {
|
||||
constructor(
|
||||
protected agentManager: QwenAgentManager,
|
||||
protected conversationStore: ConversationStore,
|
||||
protected currentConversationId: string | null,
|
||||
protected sendToWebView: (message: unknown) => void,
|
||||
) {}
|
||||
|
||||
abstract handle(message: { type: string; data?: unknown }): Promise<void>;
|
||||
abstract canHandle(messageType: string): boolean;
|
||||
|
||||
/**
|
||||
* Update current conversation ID
|
||||
*/
|
||||
setCurrentConversationId(id: string | null): void {
|
||||
this.currentConversationId = id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current conversation ID
|
||||
*/
|
||||
getCurrentConversationId(): string | null {
|
||||
return this.currentConversationId;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import * as vscode from 'vscode';
|
||||
import { BaseMessageHandler } from './BaseMessageHandler.js';
|
||||
import { getFileName } from '../utils/webviewUtils.js';
|
||||
|
||||
/**
|
||||
* Editor message handler
|
||||
* Handles all editor state-related messages
|
||||
*/
|
||||
export class EditorMessageHandler extends BaseMessageHandler {
|
||||
canHandle(messageType: string): boolean {
|
||||
return ['getActiveEditor', 'focusActiveEditor'].includes(messageType);
|
||||
}
|
||||
|
||||
async handle(message: { type: string; data?: unknown }): Promise<void> {
|
||||
switch (message.type) {
|
||||
case 'getActiveEditor':
|
||||
await this.handleGetActiveEditor();
|
||||
break;
|
||||
|
||||
case 'focusActiveEditor':
|
||||
await this.handleFocusActiveEditor();
|
||||
break;
|
||||
|
||||
default:
|
||||
console.warn(
|
||||
'[EditorMessageHandler] Unknown message type:',
|
||||
message.type,
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current active editor info
|
||||
*/
|
||||
private async handleGetActiveEditor(): Promise<void> {
|
||||
try {
|
||||
const activeEditor = vscode.window.activeTextEditor;
|
||||
|
||||
if (activeEditor) {
|
||||
const filePath = activeEditor.document.uri.fsPath;
|
||||
const fileName = getFileName(filePath);
|
||||
|
||||
let selectionInfo = null;
|
||||
if (!activeEditor.selection.isEmpty) {
|
||||
const selection = activeEditor.selection;
|
||||
selectionInfo = {
|
||||
startLine: selection.start.line + 1,
|
||||
endLine: selection.end.line + 1,
|
||||
};
|
||||
}
|
||||
|
||||
this.sendToWebView({
|
||||
type: 'activeEditorChanged',
|
||||
data: { fileName, filePath, selection: selectionInfo },
|
||||
});
|
||||
} else {
|
||||
this.sendToWebView({
|
||||
type: 'activeEditorChanged',
|
||||
data: { fileName: null, filePath: null, selection: null },
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'[EditorMessageHandler] Failed to get active editor:',
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Focus on active editor
|
||||
*/
|
||||
private async handleFocusActiveEditor(): Promise<void> {
|
||||
try {
|
||||
const activeEditor = vscode.window.activeTextEditor;
|
||||
|
||||
if (activeEditor) {
|
||||
await vscode.window.showTextDocument(activeEditor.document, {
|
||||
viewColumn: activeEditor.viewColumn,
|
||||
preserveFocus: false,
|
||||
});
|
||||
} else {
|
||||
// If no active editor, show file picker
|
||||
const uri = await vscode.window.showOpenDialog({
|
||||
defaultUri: vscode.workspace.workspaceFolders?.[0]?.uri,
|
||||
canSelectMany: false,
|
||||
canSelectFiles: true,
|
||||
canSelectFolders: false,
|
||||
openLabel: 'Open',
|
||||
});
|
||||
|
||||
if (uri && uri.length > 0) {
|
||||
await vscode.window.showTextDocument(uri[0]);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'[EditorMessageHandler] Failed to focus active editor:',
|
||||
error,
|
||||
);
|
||||
vscode.window.showErrorMessage(`Failed to focus editor: ${error}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,446 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import * as vscode from 'vscode';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { BaseMessageHandler } from './BaseMessageHandler.js';
|
||||
import { getFileName } from '../utils/webviewUtils.js';
|
||||
import { showDiffCommand } from '../../commands/index.js';
|
||||
|
||||
/**
|
||||
* File message handler
|
||||
* Handles all file-related messages
|
||||
*/
|
||||
export class FileMessageHandler extends BaseMessageHandler {
|
||||
canHandle(messageType: string): boolean {
|
||||
return [
|
||||
'attachFile',
|
||||
'showContextPicker',
|
||||
'getWorkspaceFiles',
|
||||
'openFile',
|
||||
'openDiff',
|
||||
'createAndOpenTempFile',
|
||||
].includes(messageType);
|
||||
}
|
||||
|
||||
async handle(message: { type: string; data?: unknown }): Promise<void> {
|
||||
const data = message.data as Record<string, unknown> | undefined;
|
||||
|
||||
switch (message.type) {
|
||||
case 'attachFile':
|
||||
await this.handleAttachFile();
|
||||
break;
|
||||
|
||||
case 'showContextPicker':
|
||||
await this.handleShowContextPicker();
|
||||
break;
|
||||
|
||||
case 'getWorkspaceFiles':
|
||||
await this.handleGetWorkspaceFiles(data?.query as string | undefined);
|
||||
break;
|
||||
|
||||
case 'openFile':
|
||||
await this.handleOpenFile(data?.path as string | undefined);
|
||||
break;
|
||||
|
||||
case 'openDiff':
|
||||
console.log('[FileMessageHandler ===== ] openDiff called with:', data);
|
||||
await this.handleOpenDiff(data);
|
||||
break;
|
||||
|
||||
case 'createAndOpenTempFile':
|
||||
await this.handleCreateAndOpenTempFile(data);
|
||||
break;
|
||||
|
||||
default:
|
||||
console.warn(
|
||||
'[FileMessageHandler] Unknown message type:',
|
||||
message.type,
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle attach file request
|
||||
*/
|
||||
private async handleAttachFile(): Promise<void> {
|
||||
try {
|
||||
const uris = await vscode.window.showOpenDialog({
|
||||
canSelectMany: false,
|
||||
canSelectFiles: true,
|
||||
canSelectFolders: false,
|
||||
openLabel: 'Attach',
|
||||
});
|
||||
|
||||
if (uris && uris.length > 0) {
|
||||
const uri = uris[0];
|
||||
const fileName = getFileName(uri.fsPath);
|
||||
|
||||
this.sendToWebView({
|
||||
type: 'fileAttached',
|
||||
data: {
|
||||
id: `file-${Date.now()}`,
|
||||
type: 'file',
|
||||
name: fileName,
|
||||
value: uri.fsPath,
|
||||
},
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[FileMessageHandler] Failed to attach file:', error);
|
||||
this.sendToWebView({
|
||||
type: 'error',
|
||||
data: { message: `Failed to attach file: ${error}` },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle show context picker request
|
||||
*/
|
||||
private async handleShowContextPicker(): Promise<void> {
|
||||
try {
|
||||
const items: vscode.QuickPickItem[] = [];
|
||||
|
||||
// Add current file
|
||||
const activeEditor = vscode.window.activeTextEditor;
|
||||
if (activeEditor) {
|
||||
const fileName = getFileName(activeEditor.document.uri.fsPath);
|
||||
items.push({
|
||||
label: `$(file) ${fileName}`,
|
||||
description: 'Current file',
|
||||
detail: activeEditor.document.uri.fsPath,
|
||||
});
|
||||
}
|
||||
|
||||
// Add file picker option
|
||||
items.push({
|
||||
label: '$(file) File...',
|
||||
description: 'Choose a file to attach',
|
||||
});
|
||||
|
||||
// Add workspace files option
|
||||
items.push({
|
||||
label: '$(search) Search files...',
|
||||
description: 'Search workspace files',
|
||||
});
|
||||
|
||||
const selected = await vscode.window.showQuickPick(items, {
|
||||
placeHolder: 'Attach context',
|
||||
matchOnDescription: true,
|
||||
matchOnDetail: true,
|
||||
});
|
||||
|
||||
if (selected) {
|
||||
if (selected.label.includes('Current file') && activeEditor) {
|
||||
const fileName = getFileName(activeEditor.document.uri.fsPath);
|
||||
this.sendToWebView({
|
||||
type: 'fileAttached',
|
||||
data: {
|
||||
id: `file-${Date.now()}`,
|
||||
type: 'file',
|
||||
name: fileName,
|
||||
value: activeEditor.document.uri.fsPath,
|
||||
},
|
||||
});
|
||||
} else if (selected.label.includes('File...')) {
|
||||
await this.handleAttachFile();
|
||||
} else if (selected.label.includes('Search files')) {
|
||||
const uri = await vscode.window.showOpenDialog({
|
||||
defaultUri: vscode.workspace.workspaceFolders?.[0]?.uri,
|
||||
canSelectMany: false,
|
||||
canSelectFiles: true,
|
||||
canSelectFolders: false,
|
||||
openLabel: 'Attach',
|
||||
});
|
||||
|
||||
if (uri && uri.length > 0) {
|
||||
const fileName = getFileName(uri[0].fsPath);
|
||||
this.sendToWebView({
|
||||
type: 'fileAttached',
|
||||
data: {
|
||||
id: `file-${Date.now()}`,
|
||||
type: 'file',
|
||||
name: fileName,
|
||||
value: uri[0].fsPath,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'[FileMessageHandler] Failed to show context picker:',
|
||||
error,
|
||||
);
|
||||
this.sendToWebView({
|
||||
type: 'error',
|
||||
data: { message: `Failed to show context picker: ${error}` },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get workspace files
|
||||
*/
|
||||
private async handleGetWorkspaceFiles(query?: string): Promise<void> {
|
||||
try {
|
||||
console.log('[FileMessageHandler] handleGetWorkspaceFiles start', {
|
||||
query,
|
||||
});
|
||||
const files: Array<{
|
||||
id: string;
|
||||
label: string;
|
||||
description: string;
|
||||
path: string;
|
||||
}> = [];
|
||||
const addedPaths = new Set<string>();
|
||||
|
||||
const addFile = (uri: vscode.Uri, isCurrentFile = false) => {
|
||||
if (addedPaths.has(uri.fsPath)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const fileName = getFileName(uri.fsPath);
|
||||
const workspaceFolder = vscode.workspace.getWorkspaceFolder(uri);
|
||||
const relativePath = workspaceFolder
|
||||
? vscode.workspace.asRelativePath(uri, false)
|
||||
: uri.fsPath;
|
||||
|
||||
// Filter by query if provided
|
||||
if (
|
||||
query &&
|
||||
!fileName.toLowerCase().includes(query.toLowerCase()) &&
|
||||
!relativePath.toLowerCase().includes(query.toLowerCase())
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
files.push({
|
||||
id: isCurrentFile ? 'current-file' : uri.fsPath,
|
||||
label: fileName,
|
||||
description: relativePath,
|
||||
path: uri.fsPath,
|
||||
});
|
||||
addedPaths.add(uri.fsPath);
|
||||
};
|
||||
|
||||
// Search or show recent files
|
||||
if (query) {
|
||||
// Query mode: perform filesystem search (may take longer on large workspaces)
|
||||
console.log(
|
||||
'[FileMessageHandler] Searching workspace files for query',
|
||||
query,
|
||||
);
|
||||
const uris = await vscode.workspace.findFiles(
|
||||
`**/*${query}*`,
|
||||
'**/node_modules/**',
|
||||
50,
|
||||
);
|
||||
|
||||
for (const uri of uris) {
|
||||
addFile(uri);
|
||||
}
|
||||
} else {
|
||||
// Non-query mode: respond quickly with currently active and open files
|
||||
// Add current active file first
|
||||
const activeEditor = vscode.window.activeTextEditor;
|
||||
if (activeEditor) {
|
||||
addFile(activeEditor.document.uri, true);
|
||||
}
|
||||
|
||||
// Add all open tabs
|
||||
const tabGroups = vscode.window.tabGroups.all;
|
||||
for (const tabGroup of tabGroups) {
|
||||
for (const tab of tabGroup.tabs) {
|
||||
const input = tab.input as { uri?: vscode.Uri } | undefined;
|
||||
if (input && input.uri instanceof vscode.Uri) {
|
||||
addFile(input.uri);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Send an initial quick response so UI can render immediately
|
||||
try {
|
||||
this.sendToWebView({ type: 'workspaceFiles', data: { files } });
|
||||
console.log(
|
||||
'[FileMessageHandler] Sent initial workspaceFiles (open tabs/active)',
|
||||
files.length,
|
||||
);
|
||||
} catch (e) {
|
||||
console.warn(
|
||||
'[FileMessageHandler] Failed sending initial response',
|
||||
e,
|
||||
);
|
||||
}
|
||||
|
||||
// If not enough files, add some workspace files (bounded)
|
||||
if (files.length < 10) {
|
||||
const recentUris = await vscode.workspace.findFiles(
|
||||
'**/*',
|
||||
'**/node_modules/**',
|
||||
20,
|
||||
);
|
||||
|
||||
for (const uri of recentUris) {
|
||||
if (files.length >= 20) {
|
||||
break;
|
||||
}
|
||||
addFile(uri);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.sendToWebView({ type: 'workspaceFiles', data: { files } });
|
||||
console.log(
|
||||
'[FileMessageHandler] Sent final workspaceFiles',
|
||||
files.length,
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'[FileMessageHandler] Failed to get workspace files:',
|
||||
error,
|
||||
);
|
||||
this.sendToWebView({
|
||||
type: 'error',
|
||||
data: { message: `Failed to get workspace files: ${error}` },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Open file
|
||||
*/
|
||||
private async handleOpenFile(filePath?: string): Promise<void> {
|
||||
if (!filePath) {
|
||||
console.warn('[FileMessageHandler] No path provided for openFile');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
console.log('[FileOperations] Opening file:', filePath);
|
||||
|
||||
// Parse file path, line number, and column number
|
||||
// Formats: path/to/file.ts, path/to/file.ts:123, path/to/file.ts:123:45
|
||||
const match = filePath.match(/^(.+?)(?::(\d+))?(?::(\d+))?$/);
|
||||
if (!match) {
|
||||
console.warn('[FileOperations] Invalid file path format:', filePath);
|
||||
return;
|
||||
}
|
||||
|
||||
const [, path, lineStr, columnStr] = match;
|
||||
const lineNumber = lineStr ? parseInt(lineStr, 10) - 1 : 0; // VS Code uses 0-based line numbers
|
||||
const columnNumber = columnStr ? parseInt(columnStr, 10) - 1 : 0; // VS Code uses 0-based column numbers
|
||||
|
||||
// Convert to absolute path if relative
|
||||
let absolutePath = path;
|
||||
if (!path.startsWith('/') && !path.match(/^[a-zA-Z]:/)) {
|
||||
// Relative path - resolve against workspace
|
||||
const workspaceFolder = vscode.workspace.workspaceFolders?.[0];
|
||||
if (workspaceFolder) {
|
||||
absolutePath = vscode.Uri.joinPath(workspaceFolder.uri, path).fsPath;
|
||||
}
|
||||
}
|
||||
|
||||
// Open the document
|
||||
const uri = vscode.Uri.file(absolutePath);
|
||||
const document = await vscode.workspace.openTextDocument(uri);
|
||||
const editor = await vscode.window.showTextDocument(document, {
|
||||
preview: false,
|
||||
preserveFocus: false,
|
||||
});
|
||||
|
||||
// Navigate to line and column if specified
|
||||
if (lineStr) {
|
||||
const position = new vscode.Position(lineNumber, columnNumber);
|
||||
editor.selection = new vscode.Selection(position, position);
|
||||
editor.revealRange(
|
||||
new vscode.Range(position, position),
|
||||
vscode.TextEditorRevealType.InCenter,
|
||||
);
|
||||
}
|
||||
|
||||
console.log('[FileOperations] File opened successfully:', absolutePath);
|
||||
} catch (error) {
|
||||
console.error('[FileMessageHandler] Failed to open file:', error);
|
||||
vscode.window.showErrorMessage(`Failed to open file: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Open diff view
|
||||
*/
|
||||
private async handleOpenDiff(
|
||||
data: Record<string, unknown> | undefined,
|
||||
): Promise<void> {
|
||||
if (!data) {
|
||||
console.warn('[FileMessageHandler] No data provided for openDiff');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await vscode.commands.executeCommand(showDiffCommand, {
|
||||
path: (data.path as string) || '',
|
||||
oldText: (data.oldText as string) || '',
|
||||
newText: (data.newText as string) || '',
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[FileMessageHandler] Failed to open diff:', error);
|
||||
vscode.window.showErrorMessage(`Failed to open diff: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create and open temporary file
|
||||
*/
|
||||
private async handleCreateAndOpenTempFile(
|
||||
data: Record<string, unknown> | undefined,
|
||||
): Promise<void> {
|
||||
if (!data) {
|
||||
console.warn(
|
||||
'[FileMessageHandler] No data provided for createAndOpenTempFile',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const content = (data.content as string) || '';
|
||||
const fileName = (data.fileName as string) || 'temp';
|
||||
const fileExtension = (data.fileExtension as string) || '.txt';
|
||||
|
||||
// Create temporary file path
|
||||
const tempDir = os.tmpdir();
|
||||
const tempFileName = `${fileName}-${Date.now()}${fileExtension}`;
|
||||
const tempFilePath = path.join(tempDir, tempFileName);
|
||||
|
||||
// Write content to temporary file
|
||||
await fs.promises.writeFile(tempFilePath, content, 'utf8');
|
||||
|
||||
// Open the temporary file in VS Code
|
||||
const uri = vscode.Uri.file(tempFilePath);
|
||||
await vscode.window.showTextDocument(uri, {
|
||||
preview: false,
|
||||
preserveFocus: false,
|
||||
});
|
||||
|
||||
console.log(
|
||||
'[FileMessageHandler] Created and opened temporary file:',
|
||||
tempFilePath,
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'[FileMessageHandler] Failed to create and open temporary file:',
|
||||
error,
|
||||
);
|
||||
vscode.window.showErrorMessage(
|
||||
`Failed to create and open temporary file: ${error}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { IMessageHandler } from './BaseMessageHandler.js';
|
||||
import type { QwenAgentManager } from '../../services/qwenAgentManager.js';
|
||||
import type { ConversationStore } from '../../services/conversationStore.js';
|
||||
import { SessionMessageHandler } from './SessionMessageHandler.js';
|
||||
import { FileMessageHandler } from './FileMessageHandler.js';
|
||||
import { EditorMessageHandler } from './EditorMessageHandler.js';
|
||||
import { AuthMessageHandler } from './AuthMessageHandler.js';
|
||||
import { SettingsMessageHandler } from './SettingsMessageHandler.js';
|
||||
|
||||
/**
|
||||
* Message Router
|
||||
* Routes messages to appropriate handlers
|
||||
*/
|
||||
export class MessageRouter {
|
||||
private handlers: IMessageHandler[] = [];
|
||||
private sessionHandler: SessionMessageHandler;
|
||||
private authHandler: AuthMessageHandler;
|
||||
private currentConversationId: string | null = null;
|
||||
private permissionHandler:
|
||||
| ((message: { type: string; data: { optionId: string } }) => void)
|
||||
| null = null;
|
||||
|
||||
constructor(
|
||||
agentManager: QwenAgentManager,
|
||||
conversationStore: ConversationStore,
|
||||
currentConversationId: string | null,
|
||||
sendToWebView: (message: unknown) => void,
|
||||
) {
|
||||
this.currentConversationId = currentConversationId;
|
||||
|
||||
// Initialize all handlers
|
||||
this.sessionHandler = new SessionMessageHandler(
|
||||
agentManager,
|
||||
conversationStore,
|
||||
currentConversationId,
|
||||
sendToWebView,
|
||||
);
|
||||
|
||||
const fileHandler = new FileMessageHandler(
|
||||
agentManager,
|
||||
conversationStore,
|
||||
currentConversationId,
|
||||
sendToWebView,
|
||||
);
|
||||
|
||||
const editorHandler = new EditorMessageHandler(
|
||||
agentManager,
|
||||
conversationStore,
|
||||
currentConversationId,
|
||||
sendToWebView,
|
||||
);
|
||||
|
||||
this.authHandler = new AuthMessageHandler(
|
||||
agentManager,
|
||||
conversationStore,
|
||||
currentConversationId,
|
||||
sendToWebView,
|
||||
);
|
||||
|
||||
const settingsHandler = new SettingsMessageHandler(
|
||||
agentManager,
|
||||
conversationStore,
|
||||
currentConversationId,
|
||||
sendToWebView,
|
||||
);
|
||||
|
||||
// Register handlers in order of priority
|
||||
this.handlers = [
|
||||
this.sessionHandler,
|
||||
fileHandler,
|
||||
editorHandler,
|
||||
this.authHandler,
|
||||
settingsHandler,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Route message to appropriate handler
|
||||
*/
|
||||
async route(message: { type: string; data?: unknown }): Promise<void> {
|
||||
console.log('[MessageRouter] Routing message:', message.type);
|
||||
|
||||
// Handle permission response specially
|
||||
if (message.type === 'permissionResponse') {
|
||||
if (this.permissionHandler) {
|
||||
this.permissionHandler(
|
||||
message as { type: string; data: { optionId: string } },
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Find appropriate handler
|
||||
const handler = this.handlers.find((h) => h.canHandle(message.type));
|
||||
|
||||
if (handler) {
|
||||
try {
|
||||
await handler.handle(message);
|
||||
} catch (error) {
|
||||
console.error('[MessageRouter] Handler error:', error);
|
||||
throw error;
|
||||
}
|
||||
} else {
|
||||
console.warn(
|
||||
'[MessageRouter] No handler found for message type:',
|
||||
message.type,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set current conversation ID
|
||||
*/
|
||||
setCurrentConversationId(id: string | null): void {
|
||||
this.currentConversationId = id;
|
||||
// Update all handlers
|
||||
this.handlers.forEach((handler) => {
|
||||
if ('setCurrentConversationId' in handler) {
|
||||
(
|
||||
handler as { setCurrentConversationId: (id: string | null) => void }
|
||||
).setCurrentConversationId(id);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current conversation ID
|
||||
*/
|
||||
getCurrentConversationId(): string | null {
|
||||
return this.currentConversationId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set permission handler
|
||||
*/
|
||||
setPermissionHandler(
|
||||
handler: (message: { type: string; data: { optionId: string } }) => void,
|
||||
): void {
|
||||
this.permissionHandler = handler;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set login handler
|
||||
*/
|
||||
setLoginHandler(handler: () => Promise<void>): void {
|
||||
this.authHandler.setLoginHandler(handler);
|
||||
this.sessionHandler?.setLoginHandler?.(handler);
|
||||
}
|
||||
|
||||
/**
|
||||
* Append stream content
|
||||
*/
|
||||
appendStreamContent(chunk: string): void {
|
||||
this.sessionHandler.appendStreamContent(chunk);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if saving checkpoint
|
||||
*/
|
||||
getIsSavingCheckpoint(): boolean {
|
||||
return this.sessionHandler.getIsSavingCheckpoint();
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import * as vscode from 'vscode';
|
||||
import { BaseMessageHandler } from './BaseMessageHandler.js';
|
||||
import type { ApprovalModeValue } from '../../types/acpTypes.js';
|
||||
|
||||
/**
|
||||
* Settings message handler
|
||||
* Handles all settings-related messages
|
||||
*/
|
||||
export class SettingsMessageHandler extends BaseMessageHandler {
|
||||
canHandle(messageType: string): boolean {
|
||||
return ['openSettings', 'recheckCli', 'setApprovalMode'].includes(
|
||||
messageType,
|
||||
);
|
||||
}
|
||||
|
||||
async handle(message: { type: string; data?: unknown }): Promise<void> {
|
||||
switch (message.type) {
|
||||
case 'openSettings':
|
||||
await this.handleOpenSettings();
|
||||
break;
|
||||
|
||||
case 'recheckCli':
|
||||
await this.handleRecheckCli();
|
||||
break;
|
||||
|
||||
case 'setApprovalMode':
|
||||
await this.handleSetApprovalMode(
|
||||
message.data as {
|
||||
modeId?: ApprovalModeValue;
|
||||
},
|
||||
);
|
||||
break;
|
||||
|
||||
default:
|
||||
console.warn(
|
||||
'[SettingsMessageHandler] Unknown message type:',
|
||||
message.type,
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Open settings page
|
||||
*/
|
||||
private async handleOpenSettings(): Promise<void> {
|
||||
try {
|
||||
// Open settings in a side panel
|
||||
await vscode.commands.executeCommand('workbench.action.openSettings', {
|
||||
query: 'qwenCode',
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[SettingsMessageHandler] Failed to open settings:', error);
|
||||
vscode.window.showErrorMessage(`Failed to open settings: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Recheck CLI
|
||||
*/
|
||||
private async handleRecheckCli(): Promise<void> {
|
||||
try {
|
||||
await vscode.commands.executeCommand('qwenCode.recheckCli');
|
||||
this.sendToWebView({
|
||||
type: 'cliRechecked',
|
||||
data: { success: true },
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[SettingsMessageHandler] Failed to recheck CLI:', error);
|
||||
this.sendToWebView({
|
||||
type: 'error',
|
||||
data: { message: `Failed to recheck CLI: ${error}` },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set approval mode via agent (ACP session/set_mode)
|
||||
*/
|
||||
private async handleSetApprovalMode(data?: {
|
||||
modeId?: ApprovalModeValue;
|
||||
}): Promise<void> {
|
||||
try {
|
||||
const modeId = data?.modeId || 'default';
|
||||
await this.agentManager.setApprovalModeFromUi(modeId);
|
||||
// No explicit response needed; WebView listens for modeChanged
|
||||
} catch (error) {
|
||||
console.error('[SettingsMessageHandler] Failed to set mode:', error);
|
||||
this.sendToWebView({
|
||||
type: 'error',
|
||||
data: { message: `Failed to set mode: ${error}` },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useRef } from 'react';
|
||||
import type { VSCodeAPI } from '../../hooks/useVSCode.js';
|
||||
|
||||
/**
|
||||
* File context management Hook
|
||||
* Manages active file, selection content, and workspace file list
|
||||
*/
|
||||
export const useFileContext = (vscode: VSCodeAPI) => {
|
||||
const [activeFileName, setActiveFileName] = useState<string | null>(null);
|
||||
const [activeFilePath, setActiveFilePath] = useState<string | null>(null);
|
||||
const [activeSelection, setActiveSelection] = useState<{
|
||||
startLine: number;
|
||||
endLine: number;
|
||||
} | null>(null);
|
||||
|
||||
const [workspaceFiles, setWorkspaceFiles] = useState<
|
||||
Array<{
|
||||
id: string;
|
||||
label: string;
|
||||
description: string;
|
||||
path: string;
|
||||
}>
|
||||
>([]);
|
||||
|
||||
// File reference mapping: @filename -> full path
|
||||
const fileReferenceMap = useRef<Map<string, string>>(new Map());
|
||||
|
||||
// Whether workspace files have been requested
|
||||
const hasRequestedFilesRef = useRef(false);
|
||||
|
||||
// Search debounce timer
|
||||
const searchTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
/**
|
||||
* Request workspace files
|
||||
*/
|
||||
const requestWorkspaceFiles = useCallback(
|
||||
(query?: string) => {
|
||||
if (!hasRequestedFilesRef.current && !query) {
|
||||
hasRequestedFilesRef.current = true;
|
||||
}
|
||||
|
||||
// If there's a query, clear previous timer and set up debounce
|
||||
if (query && query.length >= 1) {
|
||||
if (searchTimerRef.current) {
|
||||
clearTimeout(searchTimerRef.current);
|
||||
}
|
||||
|
||||
searchTimerRef.current = setTimeout(() => {
|
||||
vscode.postMessage({
|
||||
type: 'getWorkspaceFiles',
|
||||
data: { query },
|
||||
});
|
||||
}, 300);
|
||||
} else {
|
||||
vscode.postMessage({
|
||||
type: 'getWorkspaceFiles',
|
||||
data: query ? { query } : {},
|
||||
});
|
||||
}
|
||||
},
|
||||
[vscode],
|
||||
);
|
||||
|
||||
/**
|
||||
* Add file reference
|
||||
*/
|
||||
const addFileReference = useCallback((fileName: string, filePath: string) => {
|
||||
fileReferenceMap.current.set(fileName, filePath);
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Get file reference
|
||||
*/
|
||||
const getFileReference = useCallback(
|
||||
(fileName: string) => fileReferenceMap.current.get(fileName),
|
||||
[],
|
||||
);
|
||||
|
||||
/**
|
||||
* Clear file references
|
||||
*/
|
||||
const clearFileReferences = useCallback(() => {
|
||||
fileReferenceMap.current.clear();
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Request active editor info
|
||||
*/
|
||||
const requestActiveEditor = useCallback(() => {
|
||||
vscode.postMessage({ type: 'getActiveEditor', data: {} });
|
||||
}, [vscode]);
|
||||
|
||||
/**
|
||||
* Focus on active editor
|
||||
*/
|
||||
const focusActiveEditor = useCallback(() => {
|
||||
vscode.postMessage({
|
||||
type: 'focusActiveEditor',
|
||||
data: {},
|
||||
});
|
||||
}, [vscode]);
|
||||
|
||||
return {
|
||||
// State
|
||||
activeFileName,
|
||||
activeFilePath,
|
||||
activeSelection,
|
||||
workspaceFiles,
|
||||
hasRequestedFiles: hasRequestedFilesRef.current,
|
||||
|
||||
// State setters
|
||||
setActiveFileName,
|
||||
setActiveFilePath,
|
||||
setActiveSelection,
|
||||
setWorkspaceFiles,
|
||||
|
||||
// File reference operations
|
||||
addFileReference,
|
||||
getFileReference,
|
||||
clearFileReferences,
|
||||
|
||||
// Operations
|
||||
requestWorkspaceFiles,
|
||||
requestActiveEditor,
|
||||
focusActiveEditor,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,197 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { useState, useRef, useCallback } from 'react';
|
||||
|
||||
export interface TextMessage {
|
||||
role: 'user' | 'assistant' | 'thinking';
|
||||
content: string;
|
||||
timestamp: number;
|
||||
fileContext?: {
|
||||
fileName: string;
|
||||
filePath: string;
|
||||
startLine?: number;
|
||||
endLine?: number;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Message handling Hook
|
||||
* Manages message list, streaming responses, and loading state
|
||||
*/
|
||||
export const useMessageHandling = () => {
|
||||
const [messages, setMessages] = useState<TextMessage[]>([]);
|
||||
const [isStreaming, setIsStreaming] = useState(false);
|
||||
const [isWaitingForResponse, setIsWaitingForResponse] = useState(false);
|
||||
const [loadingMessage, setLoadingMessage] = useState('');
|
||||
// Track the index of the assistant placeholder message during streaming
|
||||
const streamingMessageIndexRef = useRef<number | null>(null);
|
||||
// Track the index of the current aggregated thinking message
|
||||
const thinkingMessageIndexRef = useRef<number | null>(null);
|
||||
|
||||
/**
|
||||
* Add message
|
||||
*/
|
||||
const addMessage = useCallback((message: TextMessage) => {
|
||||
setMessages((prev) => [...prev, message]);
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Clear messages
|
||||
*/
|
||||
const clearMessages = useCallback(() => {
|
||||
setMessages([]);
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Start streaming response
|
||||
*/
|
||||
const startStreaming = useCallback((timestamp?: number) => {
|
||||
// Create an assistant placeholder message immediately so tool calls won't jump before it
|
||||
setMessages((prev) => {
|
||||
// Record index of the placeholder to update on chunks
|
||||
streamingMessageIndexRef.current = prev.length;
|
||||
return [
|
||||
...prev,
|
||||
{
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
// Use provided timestamp (from extension) to keep ordering stable
|
||||
timestamp: typeof timestamp === 'number' ? timestamp : Date.now(),
|
||||
},
|
||||
];
|
||||
});
|
||||
setIsStreaming(true);
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Add stream chunk
|
||||
*/
|
||||
const appendStreamChunk = useCallback(
|
||||
(chunk: string) => {
|
||||
// Ignore late chunks after user cancelled streaming (until next streamStart)
|
||||
if (!isStreaming) {
|
||||
return;
|
||||
}
|
||||
|
||||
setMessages((prev) => {
|
||||
let idx = streamingMessageIndexRef.current;
|
||||
const next = prev.slice();
|
||||
|
||||
// If there is no active placeholder (e.g., after a tool call), start a new one
|
||||
if (idx === null) {
|
||||
idx = next.length;
|
||||
streamingMessageIndexRef.current = idx;
|
||||
next.push({ role: 'assistant', content: '', timestamp: Date.now() });
|
||||
}
|
||||
|
||||
if (idx < 0 || idx >= next.length) {
|
||||
return prev;
|
||||
}
|
||||
const target = next[idx];
|
||||
next[idx] = { ...target, content: (target.content || '') + chunk };
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[isStreaming],
|
||||
);
|
||||
|
||||
/**
|
||||
* Break current assistant stream segment (e.g., when a tool call starts/updates)
|
||||
* Next incoming chunk will create a new assistant placeholder
|
||||
*/
|
||||
const breakAssistantSegment = useCallback(() => {
|
||||
streamingMessageIndexRef.current = null;
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* End streaming response
|
||||
*/
|
||||
const endStreaming = useCallback(() => {
|
||||
// Finalize streaming; content already lives in the placeholder message
|
||||
setIsStreaming(false);
|
||||
streamingMessageIndexRef.current = null;
|
||||
// Remove the thinking message if it exists (collapse thoughts)
|
||||
setMessages((prev) => {
|
||||
const idx = thinkingMessageIndexRef.current;
|
||||
thinkingMessageIndexRef.current = null;
|
||||
if (idx === null || idx < 0 || idx >= prev.length) {
|
||||
return prev;
|
||||
}
|
||||
const next = prev.slice();
|
||||
next.splice(idx, 1);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Set waiting for response state
|
||||
*/
|
||||
const setWaitingForResponse = useCallback((message: string) => {
|
||||
setIsWaitingForResponse(true);
|
||||
setLoadingMessage(message);
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Clear waiting for response state
|
||||
*/
|
||||
const clearWaitingForResponse = useCallback(() => {
|
||||
setIsWaitingForResponse(false);
|
||||
setLoadingMessage('');
|
||||
}, []);
|
||||
|
||||
return {
|
||||
// State
|
||||
messages,
|
||||
isStreaming,
|
||||
isWaitingForResponse,
|
||||
loadingMessage,
|
||||
|
||||
// Operations
|
||||
addMessage,
|
||||
clearMessages,
|
||||
startStreaming,
|
||||
appendStreamChunk,
|
||||
endStreaming,
|
||||
// Thought handling
|
||||
appendThinkingChunk: (chunk: string) => {
|
||||
// Ignore late thoughts after user cancelled streaming
|
||||
if (!isStreaming) {
|
||||
return;
|
||||
}
|
||||
setMessages((prev) => {
|
||||
let idx = thinkingMessageIndexRef.current;
|
||||
const next = prev.slice();
|
||||
if (idx === null) {
|
||||
idx = next.length;
|
||||
thinkingMessageIndexRef.current = idx;
|
||||
next.push({ role: 'thinking', content: '', timestamp: Date.now() });
|
||||
}
|
||||
if (idx >= 0 && idx < next.length) {
|
||||
const target = next[idx];
|
||||
next[idx] = { ...target, content: (target.content || '') + chunk };
|
||||
}
|
||||
return next;
|
||||
});
|
||||
},
|
||||
clearThinking: () => {
|
||||
setMessages((prev) => {
|
||||
const idx = thinkingMessageIndexRef.current;
|
||||
thinkingMessageIndexRef.current = null;
|
||||
if (idx === null || idx < 0 || idx >= prev.length) {
|
||||
return prev;
|
||||
}
|
||||
const next = prev.slice();
|
||||
next.splice(idx, 1);
|
||||
return next;
|
||||
});
|
||||
},
|
||||
breakAssistantSegment,
|
||||
setWaitingForResponse,
|
||||
clearWaitingForResponse,
|
||||
setMessages,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,164 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useMemo } from 'react';
|
||||
import type { VSCodeAPI } from '../../hooks/useVSCode.js';
|
||||
|
||||
/**
|
||||
* Session management Hook
|
||||
* Manages session list, current session, session switching, and search
|
||||
*/
|
||||
export const useSessionManagement = (vscode: VSCodeAPI) => {
|
||||
const [qwenSessions, setQwenSessions] = useState<
|
||||
Array<Record<string, unknown>>
|
||||
>([]);
|
||||
const [currentSessionId, setCurrentSessionId] = useState<string | null>(null);
|
||||
const [currentSessionTitle, setCurrentSessionTitle] =
|
||||
useState<string>('Past Conversations');
|
||||
const [showSessionSelector, setShowSessionSelector] = useState(false);
|
||||
const [sessionSearchQuery, setSessionSearchQuery] = useState('');
|
||||
const [savedSessionTags, setSavedSessionTags] = useState<string[]>([]);
|
||||
const [nextCursor, setNextCursor] = useState<number | undefined>(undefined);
|
||||
const [hasMore, setHasMore] = useState<boolean>(true);
|
||||
const [isLoading, setIsLoading] = useState<boolean>(false);
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
/**
|
||||
* Filter session list
|
||||
*/
|
||||
const filteredSessions = useMemo(() => {
|
||||
if (!sessionSearchQuery.trim()) {
|
||||
return qwenSessions;
|
||||
}
|
||||
const query = sessionSearchQuery.toLowerCase();
|
||||
return qwenSessions.filter((session) => {
|
||||
const title = (
|
||||
(session.title as string) ||
|
||||
(session.name as string) ||
|
||||
''
|
||||
).toLowerCase();
|
||||
return title.includes(query);
|
||||
});
|
||||
}, [qwenSessions, sessionSearchQuery]);
|
||||
|
||||
/**
|
||||
* Load session list
|
||||
*/
|
||||
const handleLoadQwenSessions = useCallback(() => {
|
||||
// Reset pagination state and load first page
|
||||
setQwenSessions([]);
|
||||
setNextCursor(undefined);
|
||||
setHasMore(true);
|
||||
setIsLoading(true);
|
||||
vscode.postMessage({ type: 'getQwenSessions', data: { size: PAGE_SIZE } });
|
||||
setShowSessionSelector(true);
|
||||
}, [vscode]);
|
||||
|
||||
const handleLoadMoreSessions = useCallback(() => {
|
||||
if (!hasMore || isLoading || nextCursor === undefined) {
|
||||
return;
|
||||
}
|
||||
setIsLoading(true);
|
||||
vscode.postMessage({
|
||||
type: 'getQwenSessions',
|
||||
data: { cursor: nextCursor, size: PAGE_SIZE },
|
||||
});
|
||||
}, [hasMore, isLoading, nextCursor, vscode]);
|
||||
|
||||
/**
|
||||
* Create new session
|
||||
*/
|
||||
const handleNewQwenSession = useCallback(() => {
|
||||
vscode.postMessage({ type: 'openNewChatTab', data: {} });
|
||||
setShowSessionSelector(false);
|
||||
}, [vscode]);
|
||||
|
||||
/**
|
||||
* Switch session
|
||||
*/
|
||||
const handleSwitchSession = useCallback(
|
||||
(sessionId: string) => {
|
||||
if (sessionId === currentSessionId) {
|
||||
console.log('[useSessionManagement] Already on this session, ignoring');
|
||||
setShowSessionSelector(false);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[useSessionManagement] Switching to session:', sessionId);
|
||||
vscode.postMessage({
|
||||
type: 'switchQwenSession',
|
||||
data: { sessionId },
|
||||
});
|
||||
},
|
||||
[currentSessionId, vscode],
|
||||
);
|
||||
|
||||
/**
|
||||
* Save session
|
||||
*/
|
||||
const handleSaveSession = useCallback(
|
||||
(tag: string) => {
|
||||
vscode.postMessage({
|
||||
type: 'saveSession',
|
||||
data: { tag },
|
||||
});
|
||||
},
|
||||
[vscode],
|
||||
);
|
||||
|
||||
/**
|
||||
* Handle Save session response
|
||||
*/
|
||||
const handleSaveSessionResponse = useCallback(
|
||||
(response: { success: boolean; message?: string }) => {
|
||||
if (response.success) {
|
||||
if (response.message) {
|
||||
const tagMatch = response.message.match(/tag: (.+)$/);
|
||||
if (tagMatch) {
|
||||
setSavedSessionTags((prev) => [...prev, tagMatch[1]]);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.error('Failed to save session:', response.message);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return {
|
||||
// State
|
||||
qwenSessions,
|
||||
currentSessionId,
|
||||
currentSessionTitle,
|
||||
showSessionSelector,
|
||||
sessionSearchQuery,
|
||||
filteredSessions,
|
||||
savedSessionTags,
|
||||
nextCursor,
|
||||
hasMore,
|
||||
isLoading,
|
||||
|
||||
// State setters
|
||||
setQwenSessions,
|
||||
setCurrentSessionId,
|
||||
setCurrentSessionTitle,
|
||||
setShowSessionSelector,
|
||||
setSessionSearchQuery,
|
||||
setSavedSessionTags,
|
||||
setNextCursor,
|
||||
setHasMore,
|
||||
setIsLoading,
|
||||
|
||||
// Operations
|
||||
handleLoadQwenSessions,
|
||||
handleNewQwenSession,
|
||||
handleSwitchSession,
|
||||
handleSaveSession,
|
||||
handleSaveSessionResponse,
|
||||
handleLoadMoreSessions,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,298 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { RefObject } from 'react';
|
||||
import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
|
||||
import type { CompletionItem } from '../../types/completionItemTypes.js';
|
||||
|
||||
interface CompletionTriggerState {
|
||||
isOpen: boolean;
|
||||
triggerChar: '@' | '/' | null;
|
||||
query: string;
|
||||
position: { top: number; left: number };
|
||||
items: CompletionItem[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to handle @ and / completion triggers in contentEditable
|
||||
* Based on vscode-copilot-chat's AttachContextAction
|
||||
*/
|
||||
export function useCompletionTrigger(
|
||||
inputRef: RefObject<HTMLDivElement>,
|
||||
getCompletionItems: (
|
||||
trigger: '@' | '/',
|
||||
query: string,
|
||||
) => Promise<CompletionItem[]>,
|
||||
) {
|
||||
// Show immediate loading and provide a timeout fallback for slow sources
|
||||
const LOADING_ITEM = useMemo<CompletionItem>(
|
||||
() => ({
|
||||
id: 'loading',
|
||||
label: 'Loading…',
|
||||
type: 'info',
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
const TIMEOUT_ITEM = useMemo<CompletionItem>(
|
||||
() => ({
|
||||
id: 'timeout',
|
||||
label: 'Timeout',
|
||||
type: 'info',
|
||||
}),
|
||||
[],
|
||||
);
|
||||
const TIMEOUT_MS = 5000;
|
||||
|
||||
const [state, setState] = useState<CompletionTriggerState>({
|
||||
isOpen: false,
|
||||
triggerChar: null,
|
||||
query: '',
|
||||
position: { top: 0, left: 0 },
|
||||
items: [],
|
||||
});
|
||||
|
||||
// Timer for loading timeout
|
||||
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const closeCompletion = useCallback(() => {
|
||||
// Clear pending timeout
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
timeoutRef.current = null;
|
||||
}
|
||||
setState({
|
||||
isOpen: false,
|
||||
triggerChar: null,
|
||||
query: '',
|
||||
position: { top: 0, left: 0 },
|
||||
items: [],
|
||||
});
|
||||
}, []);
|
||||
|
||||
const openCompletion = useCallback(
|
||||
async (
|
||||
trigger: '@' | '/',
|
||||
query: string,
|
||||
position: { top: number; left: number },
|
||||
) => {
|
||||
// Clear previous timeout if any
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
timeoutRef.current = null;
|
||||
}
|
||||
|
||||
// Open immediately with a loading placeholder
|
||||
setState({
|
||||
isOpen: true,
|
||||
triggerChar: trigger,
|
||||
query,
|
||||
position,
|
||||
items: [LOADING_ITEM],
|
||||
});
|
||||
|
||||
// Schedule a timeout fallback if loading takes too long
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
setState((prev) => {
|
||||
// Only show timeout if still open and still for the same request
|
||||
if (
|
||||
prev.isOpen &&
|
||||
prev.triggerChar === trigger &&
|
||||
prev.query === query &&
|
||||
prev.items.length > 0 &&
|
||||
prev.items[0]?.id === 'loading'
|
||||
) {
|
||||
return { ...prev, items: [TIMEOUT_ITEM] };
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
}, TIMEOUT_MS);
|
||||
|
||||
const items = await getCompletionItems(trigger, query);
|
||||
|
||||
// Clear timeout on success
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
timeoutRef.current = null;
|
||||
}
|
||||
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
isOpen: true,
|
||||
triggerChar: trigger,
|
||||
query,
|
||||
position,
|
||||
items,
|
||||
}));
|
||||
},
|
||||
[getCompletionItems, LOADING_ITEM, TIMEOUT_ITEM],
|
||||
);
|
||||
|
||||
const refreshCompletion = useCallback(async () => {
|
||||
if (!state.isOpen || !state.triggerChar) {
|
||||
return;
|
||||
}
|
||||
const items = await getCompletionItems(state.triggerChar, state.query);
|
||||
setState((prev) => ({ ...prev, items }));
|
||||
}, [state.isOpen, state.triggerChar, state.query, getCompletionItems]);
|
||||
|
||||
useEffect(() => {
|
||||
const inputElement = inputRef.current;
|
||||
if (!inputElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
const getCursorPosition = (): { top: number; left: number } | null => {
|
||||
const selection = window.getSelection();
|
||||
if (!selection || selection.rangeCount === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const range = selection.getRangeAt(0);
|
||||
const rect = range.getBoundingClientRect();
|
||||
|
||||
// If the range has a valid position, use it
|
||||
if (rect.top > 0 && rect.left > 0) {
|
||||
return {
|
||||
top: rect.top,
|
||||
left: rect.left,
|
||||
};
|
||||
}
|
||||
|
||||
// Fallback: use input element's position
|
||||
const inputRect = inputElement.getBoundingClientRect();
|
||||
return {
|
||||
top: inputRect.top,
|
||||
left: inputRect.left,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'[useCompletionTrigger] Error getting cursor position:',
|
||||
error,
|
||||
);
|
||||
const inputRect = inputElement.getBoundingClientRect();
|
||||
return {
|
||||
top: inputRect.top,
|
||||
left: inputRect.left,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const handleInput = async () => {
|
||||
const text = inputElement.textContent || '';
|
||||
const selection = window.getSelection();
|
||||
if (!selection || selection.rangeCount === 0) {
|
||||
console.log('[useCompletionTrigger] No selection or rangeCount === 0');
|
||||
return;
|
||||
}
|
||||
|
||||
const range = selection.getRangeAt(0);
|
||||
|
||||
// Get cursor position more reliably
|
||||
// For contentEditable, we need to calculate the actual text offset
|
||||
let cursorPosition = text.length; // Default to end of text
|
||||
|
||||
if (range.startContainer === inputElement) {
|
||||
// Cursor is directly in the container (e.g., empty or at boundary)
|
||||
// Use childNodes to determine position
|
||||
const childIndex = range.startOffset;
|
||||
let offset = 0;
|
||||
for (
|
||||
let i = 0;
|
||||
i < childIndex && i < inputElement.childNodes.length;
|
||||
i++
|
||||
) {
|
||||
offset += inputElement.childNodes[i].textContent?.length || 0;
|
||||
}
|
||||
cursorPosition = offset || text.length;
|
||||
} else if (range.startContainer.nodeType === Node.TEXT_NODE) {
|
||||
// Cursor is in a text node - calculate offset from start of input
|
||||
const walker = document.createTreeWalker(
|
||||
inputElement,
|
||||
NodeFilter.SHOW_TEXT,
|
||||
null,
|
||||
);
|
||||
|
||||
let offset = 0;
|
||||
let found = false;
|
||||
let node: Node | null = walker.nextNode();
|
||||
while (node) {
|
||||
if (node === range.startContainer) {
|
||||
offset += range.startOffset;
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
offset += node.textContent?.length || 0;
|
||||
node = walker.nextNode();
|
||||
}
|
||||
// If we found the node, use the calculated offset; otherwise use text length
|
||||
cursorPosition = found ? offset : text.length;
|
||||
}
|
||||
|
||||
// Find trigger character before cursor
|
||||
// Use text length if cursorPosition is 0 but we have text (edge case for first character)
|
||||
const effectiveCursorPosition =
|
||||
cursorPosition === 0 && text.length > 0 ? text.length : cursorPosition;
|
||||
|
||||
const textBeforeCursor = text.substring(0, effectiveCursorPosition);
|
||||
const lastAtMatch = textBeforeCursor.lastIndexOf('@');
|
||||
const lastSlashMatch = textBeforeCursor.lastIndexOf('/');
|
||||
|
||||
// Check if we're in a trigger context
|
||||
let triggerPos = -1;
|
||||
let triggerChar: '@' | '/' | null = null;
|
||||
|
||||
if (lastAtMatch > lastSlashMatch) {
|
||||
triggerPos = lastAtMatch;
|
||||
triggerChar = '@';
|
||||
} else if (lastSlashMatch > lastAtMatch) {
|
||||
triggerPos = lastSlashMatch;
|
||||
triggerChar = '/';
|
||||
}
|
||||
|
||||
// Check if trigger is at word boundary (start of line or after space)
|
||||
if (triggerPos >= 0 && triggerChar) {
|
||||
const charBefore = triggerPos > 0 ? text[triggerPos - 1] : ' ';
|
||||
const isValidTrigger =
|
||||
charBefore === ' ' || charBefore === '\n' || triggerPos === 0;
|
||||
|
||||
if (isValidTrigger) {
|
||||
const query = text.substring(triggerPos + 1, effectiveCursorPosition);
|
||||
|
||||
// Only show if query doesn't contain spaces (still typing the reference)
|
||||
if (!query.includes(' ') && !query.includes('\n')) {
|
||||
// Get precise cursor position for menu
|
||||
const cursorPos = getCursorPosition();
|
||||
if (cursorPos) {
|
||||
await openCompletion(triggerChar, query, cursorPos);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Close if no valid trigger
|
||||
if (state.isOpen) {
|
||||
closeCompletion();
|
||||
}
|
||||
};
|
||||
|
||||
inputElement.addEventListener('input', handleInput);
|
||||
return () => inputElement.removeEventListener('input', handleInput);
|
||||
}, [inputRef, state.isOpen, openCompletion, closeCompletion]);
|
||||
|
||||
return {
|
||||
isOpen: state.isOpen,
|
||||
triggerChar: state.triggerChar,
|
||||
query: state.query,
|
||||
position: state.position,
|
||||
items: state.items,
|
||||
closeCompletion,
|
||||
openCompletion,
|
||||
refreshCompletion,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { useCallback } from 'react';
|
||||
import type { VSCodeAPI } from './useVSCode.js';
|
||||
import { getRandomLoadingMessage } from '../../constants/loadingMessages.js';
|
||||
|
||||
interface UseMessageSubmitProps {
|
||||
vscode: VSCodeAPI;
|
||||
inputText: string;
|
||||
setInputText: (text: string) => void;
|
||||
inputFieldRef: React.RefObject<HTMLDivElement>;
|
||||
isStreaming: boolean;
|
||||
// When true, do NOT auto-attach the active editor file/selection to context
|
||||
skipAutoActiveContext?: boolean;
|
||||
|
||||
fileContext: {
|
||||
getFileReference: (fileName: string) => string | undefined;
|
||||
activeFilePath: string | null;
|
||||
activeFileName: string | null;
|
||||
activeSelection: { startLine: number; endLine: number } | null;
|
||||
clearFileReferences: () => void;
|
||||
};
|
||||
|
||||
messageHandling: {
|
||||
setWaitingForResponse: (message: string) => void;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Message submit Hook
|
||||
* Handles message submission logic and context parsing
|
||||
*/
|
||||
export const useMessageSubmit = ({
|
||||
vscode,
|
||||
inputText,
|
||||
setInputText,
|
||||
inputFieldRef,
|
||||
isStreaming,
|
||||
skipAutoActiveContext = false,
|
||||
fileContext,
|
||||
messageHandling,
|
||||
}: UseMessageSubmitProps) => {
|
||||
const handleSubmit = useCallback(
|
||||
(e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!inputText.trim() || isStreaming) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle /login command - show inline loading while extension authenticates
|
||||
if (inputText.trim() === '/login') {
|
||||
setInputText('');
|
||||
if (inputFieldRef.current) {
|
||||
inputFieldRef.current.textContent = '';
|
||||
}
|
||||
vscode.postMessage({
|
||||
type: 'login',
|
||||
data: {},
|
||||
});
|
||||
// Show a friendly loading message in the chat while logging in
|
||||
try {
|
||||
messageHandling.setWaitingForResponse('Logging in to Qwen Code...');
|
||||
} catch (_err) {
|
||||
// Best-effort UI hint; ignore if hook not available
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
messageHandling.setWaitingForResponse(getRandomLoadingMessage());
|
||||
|
||||
// Parse @file references from input text
|
||||
const context: Array<{
|
||||
type: string;
|
||||
name: string;
|
||||
value: string;
|
||||
startLine?: number;
|
||||
endLine?: number;
|
||||
}> = [];
|
||||
const fileRefPattern = /@([^\s]+)/g;
|
||||
let match;
|
||||
|
||||
while ((match = fileRefPattern.exec(inputText)) !== null) {
|
||||
const fileName = match[1];
|
||||
const filePath = fileContext.getFileReference(fileName);
|
||||
|
||||
if (filePath) {
|
||||
context.push({
|
||||
type: 'file',
|
||||
name: fileName,
|
||||
value: filePath,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Add active file selection context if present and not skipped
|
||||
if (fileContext.activeFilePath && !skipAutoActiveContext) {
|
||||
const fileName = fileContext.activeFileName || 'current file';
|
||||
context.push({
|
||||
type: 'file',
|
||||
name: fileName,
|
||||
value: fileContext.activeFilePath,
|
||||
startLine: fileContext.activeSelection?.startLine,
|
||||
endLine: fileContext.activeSelection?.endLine,
|
||||
});
|
||||
}
|
||||
|
||||
let fileContextForMessage:
|
||||
| {
|
||||
fileName: string;
|
||||
filePath: string;
|
||||
startLine?: number;
|
||||
endLine?: number;
|
||||
}
|
||||
| undefined;
|
||||
|
||||
if (
|
||||
fileContext.activeFilePath &&
|
||||
fileContext.activeFileName &&
|
||||
!skipAutoActiveContext
|
||||
) {
|
||||
fileContextForMessage = {
|
||||
fileName: fileContext.activeFileName,
|
||||
filePath: fileContext.activeFilePath,
|
||||
startLine: fileContext.activeSelection?.startLine,
|
||||
endLine: fileContext.activeSelection?.endLine,
|
||||
};
|
||||
}
|
||||
|
||||
vscode.postMessage({
|
||||
type: 'sendMessage',
|
||||
data: {
|
||||
text: inputText,
|
||||
context: context.length > 0 ? context : undefined,
|
||||
fileContext: fileContextForMessage,
|
||||
},
|
||||
});
|
||||
|
||||
setInputText('');
|
||||
if (inputFieldRef.current) {
|
||||
inputFieldRef.current.textContent = '';
|
||||
}
|
||||
fileContext.clearFileReferences();
|
||||
},
|
||||
[
|
||||
inputText,
|
||||
isStreaming,
|
||||
setInputText,
|
||||
inputFieldRef,
|
||||
vscode,
|
||||
fileContext,
|
||||
skipAutoActiveContext,
|
||||
messageHandling,
|
||||
],
|
||||
);
|
||||
|
||||
return { handleSubmit };
|
||||
};
|
||||
253
packages/vscode-ide-companion/src/webview/hooks/useToolCalls.ts
Normal file
253
packages/vscode-ide-companion/src/webview/hooks/useToolCalls.ts
Normal file
@@ -0,0 +1,253 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import type { ToolCallData } from '../components/messages/toolcalls/ToolCall.js';
|
||||
import type { ToolCallUpdate } from '../../types/chatTypes.js';
|
||||
|
||||
/**
|
||||
* Tool call management Hook
|
||||
* Manages tool call states and updates
|
||||
*/
|
||||
export const useToolCalls = () => {
|
||||
const [toolCalls, setToolCalls] = useState<Map<string, ToolCallData>>(
|
||||
new Map(),
|
||||
);
|
||||
|
||||
/**
|
||||
* Handle tool call update
|
||||
*/
|
||||
const handleToolCallUpdate = useCallback((update: ToolCallUpdate) => {
|
||||
setToolCalls((prevToolCalls) => {
|
||||
const newMap = new Map(prevToolCalls);
|
||||
const existing = newMap.get(update.toolCallId);
|
||||
|
||||
// Helpers for todo/todos plan merging & content replacement
|
||||
const isTodoWrite = (kind?: string) =>
|
||||
(kind || '').toLowerCase() === 'todo_write' ||
|
||||
(kind || '').toLowerCase() === 'todowrite' ||
|
||||
(kind || '').toLowerCase() === 'update_todos';
|
||||
|
||||
const normTitle = (t: unknown) =>
|
||||
typeof t === 'string' ? t.trim().toLowerCase() : '';
|
||||
|
||||
const isTodoTitleMergeable = (t?: unknown) => {
|
||||
const nt = normTitle(t);
|
||||
return nt === 'updated plan' || nt === 'update todos';
|
||||
};
|
||||
|
||||
const extractText = (
|
||||
content?: Array<{
|
||||
type: 'content' | 'diff';
|
||||
content?: { text?: string };
|
||||
}>,
|
||||
): string => {
|
||||
if (!content || content.length === 0) {
|
||||
return '';
|
||||
}
|
||||
const parts: string[] = [];
|
||||
for (const item of content) {
|
||||
if (item.type === 'content' && item.content?.text) {
|
||||
parts.push(String(item.content.text));
|
||||
}
|
||||
}
|
||||
return parts.join('\n');
|
||||
};
|
||||
|
||||
const normalizeTodoLines = (text: string): string[] => {
|
||||
if (!text) {
|
||||
return [];
|
||||
}
|
||||
const lines = text
|
||||
.split(/\r?\n/)
|
||||
.map((l) => l.trim())
|
||||
.filter(Boolean);
|
||||
return lines.map((line) => {
|
||||
const idx = line.indexOf('] ');
|
||||
return idx >= 0 ? line.slice(idx + 2).trim() : line;
|
||||
});
|
||||
};
|
||||
|
||||
const isSameOrSupplement = (
|
||||
prevText: string,
|
||||
nextText: string,
|
||||
): { same: boolean; supplement: boolean } => {
|
||||
const prev = normalizeTodoLines(prevText);
|
||||
const next = normalizeTodoLines(nextText);
|
||||
if (prev.length === next.length) {
|
||||
const same = prev.every((l, i) => l === next[i]);
|
||||
if (same) {
|
||||
return { same: true, supplement: false };
|
||||
}
|
||||
}
|
||||
// supplement = prev set is subset of next set
|
||||
const setNext = new Set(next);
|
||||
const subset = prev.every((l) => setNext.has(l));
|
||||
return { same: false, supplement: subset };
|
||||
};
|
||||
|
||||
const safeTitle = (title: unknown): string => {
|
||||
if (typeof title === 'string') {
|
||||
return title;
|
||||
}
|
||||
if (title && typeof title === 'object') {
|
||||
return JSON.stringify(title);
|
||||
}
|
||||
return 'Tool Call';
|
||||
};
|
||||
|
||||
if (update.type === 'tool_call') {
|
||||
const content = update.content?.map((item) => ({
|
||||
type: item.type as 'content' | 'diff',
|
||||
content: item.content,
|
||||
path: item.path,
|
||||
oldText: item.oldText,
|
||||
newText: item.newText,
|
||||
}));
|
||||
|
||||
// Merge strategy: For todo_write + mergeable titles (Updated Plan/Update Todos),
|
||||
// if it is the same as or a supplement to the most recent similar card, merge the update instead of adding new.
|
||||
if (isTodoWrite(update.kind) && isTodoTitleMergeable(update.title)) {
|
||||
const nextText = extractText(content);
|
||||
// Find the most recent card with todo_write + mergeable title
|
||||
let lastId: string | null = null;
|
||||
let lastText = '';
|
||||
let lastTimestamp = 0;
|
||||
for (const tc of newMap.values()) {
|
||||
if (
|
||||
isTodoWrite(tc.kind) &&
|
||||
isTodoTitleMergeable(tc.title) &&
|
||||
typeof tc.timestamp === 'number' &&
|
||||
tc.timestamp >= lastTimestamp
|
||||
) {
|
||||
lastId = tc.toolCallId;
|
||||
lastText = extractText(tc.content);
|
||||
lastTimestamp = tc.timestamp || 0;
|
||||
}
|
||||
}
|
||||
|
||||
if (lastId) {
|
||||
const cmp = isSameOrSupplement(lastText, nextText);
|
||||
if (cmp.same) {
|
||||
// Completely identical: Ignore this addition
|
||||
return newMap;
|
||||
}
|
||||
if (cmp.supplement) {
|
||||
// Supplement: Replace content to the previous item (using update semantics)
|
||||
const prev = newMap.get(lastId);
|
||||
if (prev) {
|
||||
newMap.set(lastId, {
|
||||
...prev,
|
||||
content, // Override (do not append)
|
||||
status: update.status || prev.status,
|
||||
timestamp: update.timestamp || Date.now(),
|
||||
});
|
||||
return newMap;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
newMap.set(update.toolCallId, {
|
||||
toolCallId: update.toolCallId,
|
||||
kind: update.kind || 'other',
|
||||
title: safeTitle(update.title),
|
||||
status: update.status || 'pending',
|
||||
rawInput: update.rawInput as string | object | undefined,
|
||||
content,
|
||||
locations: update.locations,
|
||||
timestamp: update.timestamp || Date.now(), // Add timestamp
|
||||
});
|
||||
} else if (update.type === 'tool_call_update') {
|
||||
const updatedContent = update.content
|
||||
? update.content.map((item) => ({
|
||||
type: item.type as 'content' | 'diff',
|
||||
content: item.content,
|
||||
path: item.path,
|
||||
oldText: item.oldText,
|
||||
newText: item.newText,
|
||||
}))
|
||||
: undefined;
|
||||
|
||||
if (existing) {
|
||||
// Default behavior is to append; but for todo_write + mergeable titles, use replacement to avoid stacking duplicates
|
||||
let mergedContent = existing.content;
|
||||
if (updatedContent) {
|
||||
if (
|
||||
isTodoWrite(update.kind || existing.kind) &&
|
||||
(isTodoTitleMergeable(update.title) ||
|
||||
isTodoTitleMergeable(existing.title))
|
||||
) {
|
||||
mergedContent = updatedContent; // Override
|
||||
} else {
|
||||
mergedContent = [...(existing.content || []), ...updatedContent];
|
||||
}
|
||||
}
|
||||
// If tool call has just completed/failed, bump timestamp to now for correct ordering
|
||||
const isFinal =
|
||||
update.status === 'completed' || update.status === 'failed';
|
||||
const nextTimestamp = isFinal
|
||||
? Date.now()
|
||||
: update.timestamp || existing.timestamp || Date.now();
|
||||
|
||||
newMap.set(update.toolCallId, {
|
||||
...existing,
|
||||
...(update.kind && { kind: update.kind }),
|
||||
...(update.title && { title: safeTitle(update.title) }),
|
||||
...(update.status && { status: update.status }),
|
||||
content: mergedContent,
|
||||
...(update.locations && { locations: update.locations }),
|
||||
timestamp: nextTimestamp, // Update timestamp (use completion time when completed/failed)
|
||||
});
|
||||
} else {
|
||||
newMap.set(update.toolCallId, {
|
||||
toolCallId: update.toolCallId,
|
||||
kind: update.kind || 'other',
|
||||
title: update.title ? safeTitle(update.title) : '',
|
||||
status: update.status || 'pending',
|
||||
rawInput: update.rawInput as string | object | undefined,
|
||||
content: updatedContent,
|
||||
locations: update.locations,
|
||||
timestamp: update.timestamp || Date.now(), // Add timestamp
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return newMap;
|
||||
});
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Clear all tool calls
|
||||
*/
|
||||
const clearToolCalls = useCallback(() => {
|
||||
setToolCalls(new Map());
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Get in-progress tool calls
|
||||
*/
|
||||
const inProgressToolCalls = Array.from(toolCalls.values()).filter(
|
||||
(toolCall) =>
|
||||
toolCall.status === 'pending' || toolCall.status === 'in_progress',
|
||||
);
|
||||
|
||||
/**
|
||||
* Get completed tool calls
|
||||
*/
|
||||
const completedToolCalls = Array.from(toolCalls.values()).filter(
|
||||
(toolCall) =>
|
||||
toolCall.status === 'completed' || toolCall.status === 'failed',
|
||||
);
|
||||
|
||||
return {
|
||||
toolCalls,
|
||||
inProgressToolCalls,
|
||||
completedToolCalls,
|
||||
handleToolCallUpdate,
|
||||
clearToolCalls,
|
||||
};
|
||||
};
|
||||
54
packages/vscode-ide-companion/src/webview/hooks/useVSCode.ts
Normal file
54
packages/vscode-ide-companion/src/webview/hooks/useVSCode.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
export interface VSCodeAPI {
|
||||
postMessage: (message: unknown) => void;
|
||||
getState: () => unknown;
|
||||
setState: (state: unknown) => void;
|
||||
}
|
||||
|
||||
declare const acquireVsCodeApi: () => VSCodeAPI;
|
||||
|
||||
/**
|
||||
* Module-level VS Code API instance cache
|
||||
* acquireVsCodeApi() can only be called once, must be cached at module level
|
||||
*/
|
||||
let vscodeApiInstance: VSCodeAPI | null = null;
|
||||
|
||||
/**
|
||||
* Get VS Code API instance
|
||||
* Uses module-level cache to ensure acquireVsCodeApi() is only called once
|
||||
*/
|
||||
function getVSCodeAPI(): VSCodeAPI {
|
||||
if (vscodeApiInstance) {
|
||||
return vscodeApiInstance;
|
||||
}
|
||||
|
||||
if (typeof acquireVsCodeApi !== 'undefined') {
|
||||
vscodeApiInstance = acquireVsCodeApi();
|
||||
return vscodeApiInstance;
|
||||
}
|
||||
|
||||
// Fallback for development/testing
|
||||
vscodeApiInstance = {
|
||||
postMessage: (message: unknown) => {
|
||||
console.log('Mock postMessage:', message);
|
||||
},
|
||||
getState: () => ({}),
|
||||
setState: (state: unknown) => {
|
||||
console.log('Mock setState:', state);
|
||||
},
|
||||
};
|
||||
return vscodeApiInstance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to get VS Code API
|
||||
* Multiple components can safely call this hook, API instance will be reused
|
||||
*/
|
||||
export function useVSCode(): VSCodeAPI {
|
||||
return getVSCodeAPI();
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user