Assist
한국어 문서는 README.ko.md.
Navigation shortcuts VS Code does not ship, bound to keys VS Code does not use.
| Key |
What it does |
Alt+G / Alt+D |
Go to the definition. Press it on the definition and it goes back to the declaration. |
Ctrl+Shift+↓ / Ctrl+Shift+↑ |
Jump to the next / previous function in this file. |
Shift+Alt+S |
Search symbols in the workspace, or in one CMake project, with fuzzy matching you can switch off. |
Alt+E |
Search files in the workspace, or in one CMake project, recently opened ones first, and find them through a Korean keyboard too. |
Ctrl+Alt+P |
Attach the debugger to a running process. |
Shift+Alt+P |
Attach again to the process attached last. |
Shift+Alt+O |
Open a file anywhere in the workspace. |
Alt+M |
List the symbols in this file. |
Alt+F |
Find every reference to the symbol under the cursor, in the References panel. Press it again there to go back to the editor. |
The last three are VS Code's own Ctrl+P, Ctrl+Shift+O and Shift+Alt+F12 under a second key.
The first six rows are the ones that needed code.
Verified on Windows only. The macOS and Linux keys are in the manifest, but
nobody has run them.
The round trip
VS Code ships Go to Definition, Go to Declaration and Go to Implementation
as three one-way commands on three keys. Alt+G asks all of them at once and drops every
answer that points at the place the cursor already is. One place left is a
jump; several open a small menu at the cursor, with the one outside a header
first. Each row shows the line of code it lands on ahead of the file and line,
so two overrides read as Circle::area and Square::area rather than as two
line numbers, and a narrow menu clips the path rather than the code.
When those answers leave fewer than two places, it also looks the name up in
the workspace symbol index. Those rows are guesses - an index knows names, not
which of them you meant - so they only fill in for a server that answered with
one place or none, they keep only the namespace or class of the symbol the
providers landed on (or the cursor stands on) - when that cannot be told and a
provider found anything at all, there are none - and they always sit below the
answers the providers resolved. A name shared across namespaces used to put a
dozen of them on top of the two rows that mattered. While clangd is still
indexing, the providers often answer nothing in time, and then the qualifier
written at the cursor decides instead: on geo::scale(2) only names inside
geo (or a namespace ending in ::geo) are kept, so ui::scale no longer
comes first. A qualifier that matches none of them - an alias, say - keeps them
all.
A namespace is never a destination. It is a scope opened in many places, and
clangd answers the geo of geo::scale(2) with only the first namespace geo {
it knows. So a provider answer that lands on a namespace name, a namespace from
the name search and a namespace geo line from the text guesses are all
dropped; with the cursor on geo there is nowhere to go.
In C++, virtuals get rows of their own. An implementation answer is always an
override there, so it is labelled Override. The other direction has no
request of its own, but clangd answers a definition request made on the
override keyword with the virtual being overridden - so when the line the
cursor or a provider landed on spells override or final, Alt+G asks
there too and adds the answer as Base virtual. On the base itself that same
question tells the overrides' declarations apart from the base's own, and they
are marked Override as well.
Until clangd has indexed the project it only knows the files open in the
editor, and without a compile_commands.json it never indexes anything else.
For that stretch Alt+G adds text guesses in C/C++: lines that look like a
definition of the name, found with the ripgrep VS Code already ships. They are
not an index - nothing is kept once the menu closes. A guess never jumps on its
own; up to five open the menu, each with a similarity percentage built from what
a line of text can show (the header/source pair of what the server did find, a
geo:: qualifier against namespace geo, the argument count, a body rather
than a ;, the same folder). The search starts from the file you are reading
and widens one folder level at a time, and guesses that score the same are
listed nearest first - this file, then its folder, then further out. The last
row says how far the index has got,
counted from the files clangd leaves in .cache/clangd/index. The search only
runs while that index is incomplete and the server left fewer than two places to
go, skips names shorter than three characters and keywords, and stops after 1.5
seconds. While indexing, Alt+G also stops waiting on the server after two
seconds instead of sitting on a parse. None of this happens when clangd is not
installed or clangd.enable is false: then cpptools is the one answering, its
answers are waited for in full, and there is no clangd index to report on. Only that first line is
read; a signature wrapped before the keyword gets no base row.
| Where the cursor is |
Where you land |
| a call site |
a menu: the definition in the .cpp first, the declaration in the header next |
| the declaration, in a header |
the definition in the .cpp |
the definition, in a .cpp |
the declaration in the header |
| inside a member function's body |
the declaration in the header; for an override, a menu that adds the base virtual |
| a virtual, over its base |
the override, or a menu of them when there are several |
That table was measured with Microsoft's C/C++ extension, which answers most
positions with exactly one location - the name search is what supplies the
second row of the menu at a call site. When a jump goes somewhere unexpected,
Assist: Explain what the round trip sees here lists what each source
answered, how long each took, and which answers were dropped. While the
sources are still answering, the status bar shows a spinner - the menu can only
open once the slowest of them is done, so a server still parsing the file is a
wait with nothing else on screen.
The analysis is entirely your language server's — clangd, cpptools,
rust-analyzer, tsserver. This extension contributes no parser and no index, so
it works in any language that has a server, and it is exactly as accurate as
that server is.
Moving between functions
Ctrl+Shift+↓ and Ctrl+Shift+↑ move the cursor to the name of the next or
previous function, method or constructor in the file. The list comes from the
same language server that answers Alt+G, so it works in any language whose
server reports document symbols, and where none does the keys are not claimed.
Inside a function body, Ctrl+Shift+↑ goes to the function you are in.
On Windows those keys are a second binding for extending the selection a line
at a time, which Shift+↑ and Shift+↓ already do, so nothing is lost in the
editor. On Linux the same keys add cursors above and below, so they are not
bound there. What decides it is the machine the VS Code window runs on: a
Windows window connected to a Linux host over SSH gets the Windows keys.
Searching symbols
Shift+Alt+S opens a symbol search that matches the letters you type in order,
with gaps: ce finds Circle. VS Code's own Ctrl+T cannot do that with
clangd, and it is not VS Code's fault. That search hands the query to the
language server and can only re-filter what comes back, and clangd matches
only at the start of a name or of a word inside it, so for ce it returns
nothing. This search takes the full list the server gives for an empty query,
adds the server's answer to the query itself, and does the matching here.
The full list is not waited for - on a large project it is the slowest answer -
so rows for the query show first and the gap-matched ones join when it arrives.
Namespaces are left out, as in Alt+G.
The button in the search box turns fuzzy matching off for that search, so the
letters have to be adjacent. assist.symbolSearch.fuzzy sets where it starts.
Ctrl+T is untouched.
Test symbols - a name like TestWidget, or any symbol in a test file - go
below the rest until the query has test in it, by the same rule as the file
search (Searching files).
The highlighted letters are VS Code's own, the same bold as in its pickers: a
prefix, letters that start words, or a run - ta lights the t and the A of
totalArea. An extension cannot choose the letters, so a match that lands
elsewhere, like the ce in Circle, is listed without a highlight. Earlier
versions drew their own bold look-alike glyphs; they were too faint to read.
clangd returns at most 100 symbols per request unless it is started with
--limit-results=0. In a large project, a name you only remember the middle of
can fall outside that list. The same limit applies to code completion, which is
why it is not raised for you.
It can be narrowed to one CMake project, the same one the file search is
narrowed to (Searching files): a second button, beside the
fuzzy one, lists the projects, and the query you had typed stays. There is a
catch the file search does not have. The file search lists the files itself;
here the server picks the symbols, and a workspace symbol request has no
folder to send, so the project is applied to what the server returned - after
the 100 above. In a large tree, those 100 can all belong to other projects
while yours has the symbol. When an answer reaches 100 and a project is
chosen, a last row says the server stopped there and some of the project's
symbols may be missing, so an empty list is never a silent one;
--limit-results=0 is the fix.
Attaching the debugger
Ctrl+Alt+P opens the C/C++ extension's process picker and attaches its Windows
debugger to the process you choose, with no launch.json entry to write first.
Shift+Alt+P attaches again to the process attached last in this window. It
finds that process by its executable name, so a program that was stopped and
started again is picked up under its new process id. When several running
processes share the name you choose among them, and before anything has been
attached it opens the picker instead.
Both need the C/C++ extension (ms-vscode.cpptools): its debugger does the
work, and this extension only asks it to. They are bound on Windows only, where
that debugger can attach from a process id alone. The process attached last is
forgotten when the window reloads.
Turning a dot into an arrow
In C and C++, a . typed after a pointer is a slip of the hand every time, and
the fix is always the same three keystrokes back. With
assist.dotArrow.enabled on, the dot becomes -> as you type.
It is on by default, and it is the one thing here that changes what you
typed without being asked: everything else waits for a key, and the enum values
below only draw on the screen. If that is not wanted, turn it off for a workspace, or for one language
with a "[cpp]" block.
Whether the thing on the left is a pointer is not decided here. Right after
the dot, the language server is asked what could be completed at that spot, and
the dot is converted only when nothing on that list can be reached with a
dot - when every answer came back with an edit that reaches over the dot and
writes -> in its place. That is true of a raw pointer by definition, and it
is the whole test. Only the dot is then replaced; the member name that came
with the completion is not inserted.
Both halves of that sentence are load-bearing, and the first version of this
feature only had the first. A class with operator-> answers a dot with an
arrow too - that is how unique_ptr offers you the pointee's members - but it
answers with get, reset and release untouched alongside them, because
those really are reached with a dot. Converting on the presence of an arrow
alone rewrites .reset() into ->reset(), which is the one accident this
feature must not have. Requiring that nothing was left plain leaves smart
pointers and iterators alone without a list of exceptions to keep correct.
Measured against clangd 18, one dot at a time:
| left of the dot |
answers with an arrow |
answers left plain |
converted |
AddressMgr* |
2 |
0 |
yes |
std::unique_ptr<AddressMgr> |
2 |
5 |
no |
std::shared_ptr<AddressMgr> |
2 |
10 |
no |
std::vector<AddressMgr>::iterator |
2 |
1 |
no |
std::vector<AddressMgr> |
0 |
38 |
no |
Asking the server rather than reasoning about types also means this extension
still has no parser - the standing rule here - and that a server which does not
answer this way simply leaves your dot alone.
Three smaller decisions, in the order you would hit them:
- The conversion is applied as an edit of its own, so one
Ctrl+Z brings the
dot back and leaves the typing before it alone. An automatic change you
cannot undo in one press is not help, it is an argument.
- One
Backspace right after the conversion takes the whole ->. You
typed one character and got two; the key that would have taken the dot takes
both. This is the only moment the extension claims Backspace: a context key
is set by the conversion and cleared as soon as the cursor moves, and the
keybinding fires only while it holds. Everywhere else Backspace is not this
extension's business, and assist.keymap.enabled turns this off with the
rest.
- An answer that arrives after anything else has been typed is dropped. The
question takes a moment, and in that moment the cursor can be a word further
on; applying a stale yes there is the one way this can damage a file rather
than merely annoy.
- A dot after a digit (
3.14) is never asked about. A dot after ) or ] -
a call, a subscript, a cast - is asked the same question as a dot after a
name, because the type is never worked out here either way. Measured on
clangd 22 at 37 such positions: every expression that yields a pointer
answered with arrows only, and every value, reference, smart pointer,
optional and iterator answered with plain members alongside, so
MakeRaw(). becomes MakeRaw()-> and vec.begin(). stays as it is.
fixtures/dot-arrow/ holds every one of these cases in one file, with what to
expect at each. Turn tracing on to see what the server answered and how long it
took per dot.
Enum values
Each enumerator in a C, C++ or CUDA file gets its value at the end of its line,
as an inlay hint:
enum class Flags : unsigned char {
Read = 1 << 0, = 1 (0x1)
Write = 1 << 1, = 2 (0x2)
All = Read | Write, = 3 (0x3)
};
The number was always there, in the hover. This only brings it out, so you can
read a flag set or a protocol table without pointing at every line.
Nothing is computed here. The value is the one the language server already
worked out for its hover - clangd writes Value = 2, cpptools shows
Flags::Write = 2U - so shifts, constexpr, character literals and
uint64_t come out exactly as the compiler sees them. clangd 22 has no inlay
hint of its own for this; its answer is empty.
- A value the initializer already spells out gets no hint.
C = 10 would
only say 10 twice. Read = 1 << 0 and Ex = 'x' are what the hint is for.
A hex initializer gets the decimal alone: Mask = 0x9999 ends with
= 39321, since the hex is already on the line.
- A negative value is shown without hex. The hover does not say how wide
the underlying type is, and
0xFFFFFFFF is only right for one width.
- An enumerator whose value depends on a template argument gets nothing,
because the server has no number to give either.
- Only what is on screen is asked about, one hover at a time, and the
answers are kept until the document changes. One at a time because cpptools,
sent seventeen hovers at once, answered three.
assist.enumValues.enabled turns these off, per language if you like.
editor.inlayHints.enabled turns off every inlay hint, clangd's own included.
fixtures/enum-values/ has every shape above in one file, with the hint each
line should get.
Searching files
Alt+E opens a file search of this extension's own. Type the letters of the
path in order, each one starting a word or following the letter before - fda
reaches features/dot-arrow.js, ckc reaches check-keys copy.js - or a
whole run from anywhere, sets in CMakePresets.json. Words are counted the
way VS Code's own picker counts them, since the highlights are its own and this
rule is exactly what it can highlight: a word starts after a space or any
punctuation, at a digit, and at a capital. A name it does not read as words - a
fifth or more digits, or a fifth or fewer small letters like
CODE_OF_CONDUCT.md - has no word starts and matches by a run only; in a name
of capitals alone like README the capitals do not start words; and nothing
past the first 60 characters does. Each row puts the file name first and its
folder after, as VS Code's own picker does, so a deep path clips the folder and
never the name. A row without highlights is rare: a match that runs from the
folder into the name, a query with spaces in it, or one typed through a Korean
keyboard. What the rule gives up is the consonant skeleton - fzy no longer
reaches fuzzy.js here - and abbreviations of names like CODE_OF_CONDUCT.md;
Quick Open still takes both.
The list is ordered the way VS Code's own picker orders it. Before you type,
it shows the files you opened recently in this workspace, newest first, followed
by whatever else is open in a tab; the file you are in is left out, so Alt+E
Enter steps back to the previous one. Once you type, those recent files stay
on top under recently opened, matched like the rest but by the file name
alone - a folder name would match most of them at once. VS Code's own
picker wants the letters adjacent there; this one does not, so the file you
just left still comes first when you type it as an abbreviation. Every other
file follows under file results. There, a hit in the file name always ranks above a hit that
needs the folders, and a name that starts with what you typed ranks above both;
fda still reaches features/dot-arrow.js by its folders, it just no longer
beats a file whose own name holds those letters. Spaces split the query into
pieces that must all match, in any order - dot js. VS Code does not let an
extension read its editor history, so this one keeps its own list of the last
50 files per workspace; it starts empty on a new install, which is why the open
tabs are added below it. A file deleted or renamed since - in VS Code, a
terminal or git - is looked up each time the search opens, and dropped.
Test files sink to the bottom of the file results until the query has
test in it, typed whole. A test file usually carries the name of the file it
tests, so widget would otherwise put tests/widget.cpp above the
widget_impl.cpp you were after. A test file is one whose path holds a word
starting with test - test_foo.py, foo.test.js, tests/, FooTest.cpp,
testdata/ - so latest.js and contest/ are left alone. "test" fused into a
longer word, as in googletest or unittest, is not caught. They are lowered,
not hidden: while you are still typing tes, the list does not empty out.
ㅅㄷㄴㅅ, typed with the Korean input method on, counts as test. The
recently opened section is not reordered this way - you opened those files
yourself.
Files in a CMake build folder go below even those, whatever you type. A
folder holding a CMakeCache.txt is one CMake configured to build into, and
what is under it - object files, generated sources, copied headers - shares the
names of the sources. This is for the build folders a project does not ignore:
build/ may be in .gitignore while a build-a/ beside it is not. A cache at
the top of a workspace folder is an in-source build, so nothing is lowered
there. Like test files they are lowered, not hidden - when the generated file is
the one you want, it is still in the list - and the recently opened section is
left as it is.
What it lists is what Quick Open lists. The list comes from the ripgrep VS
Code ships, run the way Quick Open runs it: hidden files in, files.exclude
and search.exclude out, and .gitignore, .ignore and .git/info/exclude
honoured - so a build folder the project ignores, or a worktree under
.claude/, does not show. The three search.use*IgnoreFiles settings and
search.followSymlinks are read the same way. There is no list here of what
build output looks like - only the CMake build folders above are told apart,
and they are lowered, not left out. If an .obj shows up, it shows up in Quick
Open too, and the place to take it out is the project's .gitignore - or
search.exclude, which keeps it out of VS Code's own search as well. At most
50,000 files are listed; a larger workspace gets a row at the bottom saying the
listing stopped there, so a file missing from it is never a silent one. Each
path is lowered once when the list is made, its word starts are worked out the
first time a query needs them and kept, and a query that only adds letters to
the last one is matched against the last one's hits alone - typing quick goes
through the whole list once, at q.
It can be narrowed to one CMake project. When CMake Tools has configured
the folder, a filter button sits at the top right of the search box; it lists
Whole workspace and every project() that configure went through, each with the
CMakeLists.txt that declared it. A project is its folder and everything
under it, the folders of the projects it adds included - so the top project, at
the root CMakeLists.txt, is the whole tree, and a project below it is that
part of it. Both sections, recently opened and file results, keep only what is
inside, and the query you had typed stays. The list is CMake Tools' own code
model, the one its active preset or kit last configured, not a reading of
CMakeLists.txt: a subdirectory an option leaves out is not configured, and
neither is a project in it. Target source lists were the other way to draw the
line, and were left - a target lists sources from other folders and leaves out
most headers. The choice is kept per workspace; a project a reconfigure drops
or moves is forgotten, and the search goes back to the whole workspace. Without
CMake Tools, or before its first configure, there is no button. The choice is
shared with Shift+Alt+S (Searching symbols): narrowing
one narrows the other. CMake Tools is
not started by this extension, it starts itself in a folder with a
CMakeLists.txt.
It also finds files through a Korean keyboard. Code is written in English
and file names follow it, but an input method left switched on does not know
that: reaching for abcd puts 뮻ㅇ in the box, because the keys land as jamo
and the jamo compose into syllables. Every jamo came from exactly one key, so
the query is simply read twice - as itself, and as the keys that produced it -
and whichever reading scores higher is the one you see. Nothing detects a
language and nothing is guessed at, so a file actually named in Korean still
matches as itself.
Shift+Alt+O still opens VS Code's own file picker, unchanged. That is not
a hedge: an extension cannot read what is typed into that box, cannot replace
the matcher behind it, and can only open it. So this is a second key rather than
a replacement - keys are added here, not taken - and VS Code's picker keeps what
it is better at: :42 to jump to a line, @ for symbols, and a history that
reaches back further than this window's own. This window has none of those.
When it feels slow
Assist: Start or stop tracing is a debugging aid, off until you start it.
While it runs the status bar shows Assist: tracing - click it to stop - and
every Alt+G, Shift+Alt+S and function step writes one line to a
temporary file: when each stage finished, how many answers it had, and the
total. Stopping asks where to save it as a text file; saved or not, the
temporary file is deleted. Closing the window mid-trace deletes it too, and one
left behind by a crash is deleted the next time VS Code starts. The lines hold
file paths, symbol names and what you typed into symbol search, which is why
tracing never runs on its own and never goes to an output channel, whose
contents VS Code copies into its session logs. Alt+G lists the providers, the base virtual lookup, the name search
(or that it was not waited for) and the menu; symbol search lists the server's
answer, the full list, and how long the matching here took.
When something feels off, run Assist: Write a note about what just felt wrong
right there. It asks what felt wrong and opens an unsaved Markdown note with
that sentence, the versions and settings of clangd and cpptools, the cursor's
file, line and word, and timings of every stage at the cursor asked one at a
time - including the one the trace cannot see, how long VS Code takes to
collect code actions before the menu opens - followed by the last 50 trace
lines when tracing was on. Nothing is written until you save it. Read it before sharing: it carries
file paths, symbol names and the line of code under the cursor.
Some waiting is avoided up front. Ctrl+Shift+↓ pressed in a row reuses the
outline while the file is unchanged, and a symbol search query that only adds
letters rescans what the last one matched rather than the whole list.
How this extension treats your keyboard
A keymap extension can make a mess of a keyboard, so this one is built around
four rules.
It adds keys, it does not take them. A conflict happens when a key that
already did something starts doing something else. Ctrl+T and Ctrl+Shift+O
still work; Alt+M and Alt+F are second keys onto the same commands, and Shift+Alt+S
opens a search of its own next to Ctrl+T rather than instead of it. Nothing
is displaced.
Every key is checked against the default keymap first. tools/check-keys.js
reads the shipped defaults for Windows, macOS and Linux and reports whether a
candidate key is already spoken for. Run it before adding a binding, and on a
key you are considering:
node tools/check-keys.js alt+o
A key that is spoken for can still be shared, if the rule holding it is
conditional. That is how Shift+Alt+O got in. By default it is Organize Imports, but only in a file whose language server can organize imports, and
cpptools cannot, so in C++ the key does nothing. This extension binds it to
Quick Open, and then binds it once more, after that, back to Organize Imports
under the default's own when clause. The later rule wins wherever both apply:
a TypeScript file still organizes its imports, a C++ file opens Quick Open.
check-keys reports that pair as yields rather than taken.
Every binding is scoped as tightly as it can be. Alt+G carries
editorHasDefinitionProvider, so in a Markdown file or a settings tab the key is
not claimed at all and whatever else wants it keeps working.
One switch hands everything back. Turn off assist.keymap.enabled and every
key this extension binds is released at once, because each when clause carries
it. The commands stay available from the Command Palette and from your own
keybindings.json.
That last one exists because of an asymmetry worth knowing about: rules in your
keybindings.json are appended below both the defaults and every extension's,
so you always win — and you can remove any rule by prefixing the command with
-. An extension has no equivalent. Once it takes a key, a when clause is the
only thing that can give it back.
Settings
| Setting |
Default |
|
assist.keymap.enabled |
true |
Master switch for every contributed key. |
assist.roundTrip.providers |
["definition", "implementation", "declaration"] |
Which providers Alt+G asks. The order only breaks ties. |
assist.roundTrip.searchByName |
true |
Also look the name up in the workspace symbol index. |
assist.roundTrip.pickWhenAmbiguous |
true |
Show a menu when several locations answer. |
assist.roundTrip.textSearch |
true |
While clangd's index is incomplete, add text guesses with a similarity percentage. |
assist.symbolSearch.fuzzy |
true |
Start Shift+Alt+S with fuzzy matching on. |
assist.dotArrow.enabled |
true |
Turn a . typed after a pointer into ->, in C, C++ and CUDA. |
assist.enumValues.enabled |
true |
Show each enumerator's value at the end of its line, in C, C++ and CUDA. |
Rebinding
Keyboard Shortcuts, search for Assist. Or in keybindings.json:
{ "key": "alt+g", "command": "assist.roundTrip", "when": "editorTextFocus" }
To get back where you came from, VS Code's own Go Back (Alt+Left) covers it —
these jumps land in the same navigation history.
When the file a jump lands in is already showing in another split, the jump goes
to that split instead of opening the file again over the one you were in.
Developing
No build step and no dependencies. Clone it, open it, press F5; a second
VS Code window opens with the extension loaded.
A feature that needs code is a file in features/ exporting
{ commands: { id: handler } }, a line in the list at the top of
extension.js, and a contributes entry. A feature that only needs a key is
the contributes entry alone. A feature that answers something other than a
key press exports activate(context) instead and puts its listener there.
Everything on screen is written twice - English and Korean - in two different
places: command titles and setting descriptions in package.nls.json and
package.nls.ko.json, everything the code says in l10n/bundle.l10n.ko.json,
where the key is the English sentence itself.
npm run check-nls
checks that the two halves still match, in both directions, and that the {0}
slots survived the translation. It is worth a habit, because a missing
translation does not fail: VS Code shows the English and the extension keeps
working, so nothing tells you until someone reads the Korean.
More, for the features that read what a server answers:
npm run check-dot-arrow the two decisions it makes without a server
npm run check-enum-values reading a value out of a hover, and when to stay quiet
npm run check-fuzzy the Hangul table and how paths score
npm run probe-clangd what clangd actually answers, over LSP
The second one talks to a real clangd with no editor in the way, types a dot at
every position in fixtures/dot-arrow/, and prints what came back. It exists
because this feature was once built on a guess about what a language server
answers, and the guess was wrong in a way that would have corrupted people's
files. Measure before changing that rule.
License
MIT