Skip to content

Commit dad53e9

Browse files
authored
Stack the azure.ai.agents Next: guidance block with command highlighting (#8731)
* Stack agent "Next:" guidance with indentation and command highlighting Restyle the post-deploy (and show/init/doctor) "Next:" guidance block in the azure.ai.agents extension. Each suggestion now renders as a command line over its description line, indented under a standalone "Next:" header with a blank line between suggestions, replacing the dense right-aligned "cmd -- desc" rows. Runnable azd commands are highlighted blue; "see ..." and "edit agent.yaml: ..." pointers stay plain. - format.go: stacked renderer plus highlightCommand helper; FormatNextForNote emits a leading blank line and drops the 4-space continuation pre-indent. - service_target_agent.go: join the appended Next: block with a single newline since the block now carries its own leading newline. - format_test.go: new layout goldens, TestMain color suppression, and a highlight-routing test. * Add CHANGELOG entry for #8731 * Keep Next: artifact note plain to avoid ANSI in --output json Address Copilot PR review: highlightCommand applied color via output.WithHighLightFormat, which only self-gates on NO_COLOR/non-TTY -- not azd's --output json mode. Because FormatNextForNote's result is embedded in an artifact note that azd serializes into `azd deploy --output json`, and the service-target Deploy path has no output-format signal, color could leak ANSI escape codes into JSON. Plumb an explicit colorize signal from call sites: PrintNext/PrintAllNext take a colorize bool (terminal call sites pass true; doctor threads its TTY-derived showNext), while FormatNextForNote always renders plain. Fix the inaccurate doc comment that claimed WithHighLightFormat self-disables for JSON. Add regression tests asserting FormatNextForNote never emits ANSI even with color enabled, and that the terminal path highlights only azd commands. * Restore Next: command highlighting in terminal output Revert the unconditional plain-note change (047b6ff) so the post-deploy Next: block highlights azd commands again in regular (color) terminal mode. Highlighting follows the host's color state: azd core sets FORCE_COLOR for the extension only when core itself is in color mode (interactive TTY, NO_COLOR unset), so piped/redirected/NO_COLOR runs stay plain. Replace the inaccurate doc comment that claimed WithHighLightFormat self-disables for --output json. It does not; document the actual FORCE_COLOR-driven behavior and note the residual --output-json-on-a-TTY gap (closing it fully needs a core change, and the common scripted JSON case is non-TTY so it is already plain).
1 parent b799477 commit dad53e9

4 files changed

Lines changed: 246 additions & 108 deletions

File tree

cli/azd/extensions/azure.ai.agents/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
## 0.1.41-preview (2026-06-19)
44

5+
- [[#8731]](https://github.com/Azure/azure-dev/pull/8731) Improve the post-deploy `Next:` guidance with a stacked layout that puts each command on its own line above its description, adds a blank line between suggestions, and highlights `azd` commands. The new layout applies across deploy, `azd ai agent show`, `init`, and `doctor`. Thanks @therealjohn for the contribution!
56
- [[#8645]](https://github.com/Azure/azure-dev/pull/8645) Detect VNET-injected Foundry accounts during `azd ai agent init` and skip remote builds up front so hosted container agents use local builds without a failing remote-build attempt first. Thanks @m5i-work for the contribution!
67
- [[#8714]](https://github.com/Azure/azure-dev/pull/8714) Show a tracing disclaimer when `azd ai agent init` connects or adds an Application Insights connection. Thanks @therealjohn for the contribution!
78
- [[#8685]](https://github.com/Azure/azure-dev/pull/8685) Default `azd ai agent run` local Python virtual environments to Python >= 3.13 so local runs match the minimum supported Foundry runtime. Thanks @therealjohn for the contribution!

cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/format.go

Lines changed: 69 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -7,30 +7,34 @@ import (
77
"io"
88
"slices"
99
"strings"
10+
11+
"github.com/azure/azure-dev/cli/azd/pkg/output"
1012
)
1113

1214
const (
13-
// primaryPrefix is the leading label of the first suggestion line.
14-
primaryPrefix = "Next: "
15-
// continuationPrefix indents subsequent lines so commands align under
16-
// the first command. Width == len(primaryPrefix).
17-
continuationPrefix = " "
18-
// commandSeparator separates the (possibly padded) command from its
19-
// description. Two-space gap + "-- " per the design spec.
20-
commandSeparator = " -- "
21-
// maxRendered caps the block at one primary + one optional secondary
22-
// line ("more than two lines drowns out command output").
15+
// header labels the guidance block. It sits on its own line; the
16+
// suggestions are stacked beneath it, indented by bodyIndent.
17+
header = "Next:"
18+
// bodyIndent indents each command and description line under the
19+
// header, creating a clear visual hierarchy ("Next:" at the margin,
20+
// the actionable lines stepped in beneath it).
21+
bodyIndent = " "
22+
// maxRendered caps PrintNext at this many suggestions (one primary
23+
// plus one optional Trailing footer). PrintAllNext and
24+
// FormatNextForNote are uncapped.
2325
maxRendered = 2
2426
)
2527

2628
// PrintNext writes a "Next:" guidance block to w. Suggestions are sorted
2729
// ascending by Priority (stable; ties preserve input order) and then
28-
// truncated to a primary + optional secondary line. Empty input produces
29-
// no output and no write.
30+
// truncated to a primary plus an optional Trailing suggestion. Empty
31+
// input produces no output and no write.
3032
//
3133
// PrintNext does not inspect TTY state or output-format flags — those
3234
// decisions live at the call site so the same renderer can serve both
3335
// interactive stdout writes and string capture for tests / JSON envelopes.
36+
// Command highlighting is applied by the renderer via output.WithHighLightFormat,
37+
// which self-gates on color settings (NO_COLOR / non-TTY yield plain text).
3438
//
3539
// Use PrintAllNext when the resolver produces multiple REQUIRED follow-up
3640
// actions (init / doctor fix-ups) where silently dropping any of them
@@ -69,24 +73,25 @@ func PrintAllNext(w io.Writer, suggestions []Suggestion) error {
6973
// FormatNextForNote renders a "Next:" block as a string suitable for
7074
// embedding in an artifact's Metadata["note"]. Unlike PrintNext it does
7175
// not truncate the block (the artifact note is a contained region, not
72-
// interleaved with command output) and does not include a leading or
73-
// trailing newline (the artifact renderer adds its own line break).
76+
// interleaved with command output).
7477
//
75-
// Lines 2+ are pre-indented by 4 spaces so the command column stays
76-
// aligned with line 1 when core azd's artifact renderer (which only
77-
// indents the first line of the note) is called with the typical caller
78-
// indent of two spaces — see cli/azd/pkg/project/artifact.go, which
79-
// writes "\n%s %s" with the caller's indent on line 1 only. Under
80-
// deeper or shallower caller indents the lines drift slightly but the
81-
// note remains readable in both cases.
78+
// The returned string begins with a newline and has no trailing newline.
79+
// Core azd's artifact renderer appends the note via "\n%s %s" with the
80+
// caller indent applied to the first line only (see
81+
// cli/azd/pkg/project/artifact.go). The leading newline turns that first,
82+
// indent-only line into a blank separator, after which the "Next:" header
83+
// and its stacked, bodyIndent-indented suggestions render beneath the
84+
// endpoint. The deploy path renders artifacts at the zero indent, so the
85+
// header lands in the same column as the endpoint bullet and each
86+
// suggestion steps in by bodyIndent.
8287
//
8388
// Empty input returns an empty string.
8489
func FormatNextForNote(suggestions []Suggestion) string {
8590
body := renderRows(suggestions, 0)
8691
if body == "" {
8792
return ""
8893
}
89-
return strings.ReplaceAll(strings.TrimSuffix(body, "\n"), "\n", "\n ")
94+
return "\n" + strings.TrimSuffix(body, "\n")
9095
}
9196

9297
// renderBlock returns the formatted "Next:" block (with a leading blank
@@ -103,8 +108,10 @@ func renderBlock(suggestions []Suggestion, limit int) string {
103108
return "\n" + body
104109
}
105110

106-
// renderRows returns the formatted suggestion lines (one per line,
107-
// terminated with "\n") with no leading blank line. limit caps the
111+
// renderRows returns the formatted "Next:" body: a header line followed
112+
// by each suggestion rendered as an indented command line, its indented
113+
// description line, and a blank line separating consecutive suggestions.
114+
// The body has no leading blank line and ends with "\n". limit caps the
108115
// number of visible suggestions; limit <= 0 means render every
109116
// suggestion.
110117
//
@@ -162,27 +169,48 @@ func renderRows(suggestions []Suggestion, limit int) string {
162169
return ""
163170
}
164171

165-
cmdWidth := 0
166-
for _, s := range rendered {
167-
if n := len(s.Command); n > cmdWidth {
168-
cmdWidth = n
169-
}
170-
}
171-
172172
var b strings.Builder
173+
b.WriteString(header)
174+
b.WriteByte('\n')
173175
for i, s := range rendered {
174-
if i == 0 {
175-
b.WriteString(primaryPrefix)
176-
} else {
177-
b.WriteString(continuationPrefix)
178-
}
179-
b.WriteString(s.Command)
180-
if pad := cmdWidth - len(s.Command); pad > 0 {
181-
b.WriteString(strings.Repeat(" ", pad))
176+
if i > 0 {
177+
// Blank line between suggestions so each command/description
178+
// pair reads as a distinct step.
179+
b.WriteByte('\n')
182180
}
183-
b.WriteString(commandSeparator)
184-
b.WriteString(s.Description)
181+
b.WriteString(bodyIndent)
182+
b.WriteString(highlightCommand(s.Command))
185183
b.WriteByte('\n')
184+
if s.Description != "" {
185+
b.WriteString(bodyIndent)
186+
b.WriteString(s.Description)
187+
b.WriteByte('\n')
188+
}
186189
}
187190
return b.String()
188191
}
192+
193+
// highlightCommand returns cmd wrapped in the highlight (blue) color when
194+
// it is a runnable azd command (prefix "azd "), and cmd unchanged
195+
// otherwise. Non-command suggestions — "see <path>/README.md" pointers and
196+
// "edit agent.yaml: ..." instructions — stay plain.
197+
//
198+
// output.WithHighLightFormat gates on color.NoColor. In this extension that
199+
// flag is driven by the FORCE_COLOR env var azd core sets when core itself
200+
// is in color mode (interactive TTY, NO_COLOR unset); for piped, redirected,
201+
// or NO_COLOR runs core omits it, so color.NoColor is true and commands stay
202+
// plain. This keeps highlighting aligned with the host's color state without
203+
// the renderer inspecting TTY/output flags directly.
204+
//
205+
// Known limitation: core sets FORCE_COLOR from its own TTY state without
206+
// accounting for --output json, so a block rendered into an artifact note
207+
// (see FormatNextForNote) during `--output json` from a color TTY can carry
208+
// ANSI into the JSON. Closing that gap requires a core change (skip
209+
// FORCE_COLOR for JSON output); the common scripted/piped JSON case is
210+
// non-TTY, so color.NoColor is already true there and the note stays plain.
211+
func highlightCommand(cmd string) string {
212+
if strings.HasPrefix(cmd, "azd ") {
213+
return output.WithHighLightFormat("%s", cmd)
214+
}
215+
return cmd
216+
}

0 commit comments

Comments
 (0)