feat: add shell tool display mode preference (#25029)

This commit is contained in:
Danielle Maywood
2026-05-14 14:25:07 +01:00
committed by GitHub
parent 3a070a83dd
commit 25a803221e
30 changed files with 964 additions and 332 deletions
+6
View File
@@ -23572,6 +23572,9 @@ const docTemplate = `{
"code_diff_display_mode": {
"$ref": "#/definitions/codersdk.AgentDisplayMode"
},
"shell_tool_display_mode": {
"$ref": "#/definitions/codersdk.AgentDisplayMode"
},
"task_notification_alert_dismissed": {
"type": "boolean"
},
@@ -24060,6 +24063,9 @@ const docTemplate = `{
"code_diff_display_mode": {
"$ref": "#/definitions/codersdk.AgentDisplayMode"
},
"shell_tool_display_mode": {
"$ref": "#/definitions/codersdk.AgentDisplayMode"
},
"task_notification_alert_dismissed": {
"type": "boolean"
},
+6
View File
@@ -21694,6 +21694,9 @@
"code_diff_display_mode": {
"$ref": "#/definitions/codersdk.AgentDisplayMode"
},
"shell_tool_display_mode": {
"$ref": "#/definitions/codersdk.AgentDisplayMode"
},
"task_notification_alert_dismissed": {
"type": "boolean"
},
@@ -22153,6 +22156,9 @@
"code_diff_display_mode": {
"$ref": "#/definitions/codersdk.AgentDisplayMode"
},
"shell_tool_display_mode": {
"$ref": "#/definitions/codersdk.AgentDisplayMode"
},
"task_notification_alert_dismissed": {
"type": "boolean"
},
+22
View File
@@ -4546,6 +4546,17 @@ func (q *querier) GetUserSecretsTelemetrySummary(ctx context.Context) (database.
return q.db.GetUserSecretsTelemetrySummary(ctx)
}
func (q *querier) GetUserShellToolDisplayMode(ctx context.Context, userID uuid.UUID) (string, error) {
user, err := q.db.GetUserByID(ctx, userID)
if err != nil {
return "", err
}
if err := q.authorizeContext(ctx, policy.ActionReadPersonal, user); err != nil {
return "", err
}
return q.db.GetUserShellToolDisplayMode(ctx, userID)
}
func (q *querier) GetUserStatusCounts(ctx context.Context, arg database.GetUserStatusCountsParams) ([]database.GetUserStatusCountsRow, error) {
if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceUser); err != nil {
return nil, err
@@ -7213,6 +7224,17 @@ func (q *querier) UpdateUserSecretByUserIDAndName(ctx context.Context, arg datab
return q.db.UpdateUserSecretByUserIDAndName(ctx, arg)
}
func (q *querier) UpdateUserShellToolDisplayMode(ctx context.Context, arg database.UpdateUserShellToolDisplayModeParams) (string, error) {
user, err := q.db.GetUserByID(ctx, arg.UserID)
if err != nil {
return "", err
}
if err := q.authorizeContext(ctx, policy.ActionUpdatePersonal, user); err != nil {
return "", err
}
return q.db.UpdateUserShellToolDisplayMode(ctx, arg)
}
func (q *querier) UpdateUserStatus(ctx context.Context, arg database.UpdateUserStatusParams) (database.User, error) {
fetch := func(ctx context.Context, arg database.UpdateUserStatusParams) (database.User, error) {
return q.db.GetUserByID(ctx, arg.ID)
+13
View File
@@ -2862,6 +2862,19 @@ func (s *MethodTestSuite) TestUser() {
dbm.EXPECT().UpdateUserThinkingDisplayMode(gomock.Any(), arg).Return("always_expanded", nil).AnyTimes()
check.Args(arg).Asserts(u, policy.ActionUpdatePersonal).Returns("always_expanded")
}))
s.Run("GetUserShellToolDisplayMode", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
u := testutil.Fake(s.T(), faker, database.User{})
dbm.EXPECT().GetUserByID(gomock.Any(), u.ID).Return(u, nil).AnyTimes()
dbm.EXPECT().GetUserShellToolDisplayMode(gomock.Any(), u.ID).Return("auto", nil).AnyTimes()
check.Args(u.ID).Asserts(u, policy.ActionReadPersonal).Returns("auto")
}))
s.Run("UpdateUserShellToolDisplayMode", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
u := testutil.Fake(s.T(), faker, database.User{})
arg := database.UpdateUserShellToolDisplayModeParams{UserID: u.ID, ShellToolDisplayMode: "always_collapsed"}
dbm.EXPECT().GetUserByID(gomock.Any(), u.ID).Return(u, nil).AnyTimes()
dbm.EXPECT().UpdateUserShellToolDisplayMode(gomock.Any(), arg).Return("always_collapsed", nil).AnyTimes()
check.Args(arg).Asserts(u, policy.ActionUpdatePersonal).Returns("always_collapsed")
}))
s.Run("GetUserCodeDiffDisplayMode", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
u := testutil.Fake(s.T(), faker, database.User{})
dbm.EXPECT().GetUserByID(gomock.Any(), u.ID).Return(u, nil).AnyTimes()
+16
View File
@@ -2969,6 +2969,14 @@ func (m queryMetricsStore) GetUserSecretsTelemetrySummary(ctx context.Context) (
return r0, r1
}
func (m queryMetricsStore) GetUserShellToolDisplayMode(ctx context.Context, userID uuid.UUID) (string, error) {
start := time.Now()
r0, r1 := m.s.GetUserShellToolDisplayMode(ctx, userID)
m.queryLatencies.WithLabelValues("GetUserShellToolDisplayMode").Observe(time.Since(start).Seconds())
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "GetUserShellToolDisplayMode").Inc()
return r0, r1
}
func (m queryMetricsStore) GetUserStatusCounts(ctx context.Context, arg database.GetUserStatusCountsParams) ([]database.GetUserStatusCountsRow, error) {
start := time.Now()
r0, r1 := m.s.GetUserStatusCounts(ctx, arg)
@@ -5153,6 +5161,14 @@ func (m queryMetricsStore) UpdateUserSecretByUserIDAndName(ctx context.Context,
return r0, r1
}
func (m queryMetricsStore) UpdateUserShellToolDisplayMode(ctx context.Context, arg database.UpdateUserShellToolDisplayModeParams) (string, error) {
start := time.Now()
r0, r1 := m.s.UpdateUserShellToolDisplayMode(ctx, arg)
m.queryLatencies.WithLabelValues("UpdateUserShellToolDisplayMode").Observe(time.Since(start).Seconds())
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "UpdateUserShellToolDisplayMode").Inc()
return r0, r1
}
func (m queryMetricsStore) UpdateUserStatus(ctx context.Context, arg database.UpdateUserStatusParams) (database.User, error) {
start := time.Now()
r0, r1 := m.s.UpdateUserStatus(ctx, arg)
+30
View File
@@ -5553,6 +5553,21 @@ func (mr *MockStoreMockRecorder) GetUserSecretsTelemetrySummary(ctx any) *gomock
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUserSecretsTelemetrySummary", reflect.TypeOf((*MockStore)(nil).GetUserSecretsTelemetrySummary), ctx)
}
// GetUserShellToolDisplayMode mocks base method.
func (m *MockStore) GetUserShellToolDisplayMode(ctx context.Context, userID uuid.UUID) (string, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetUserShellToolDisplayMode", ctx, userID)
ret0, _ := ret[0].(string)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// GetUserShellToolDisplayMode indicates an expected call of GetUserShellToolDisplayMode.
func (mr *MockStoreMockRecorder) GetUserShellToolDisplayMode(ctx, userID any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUserShellToolDisplayMode", reflect.TypeOf((*MockStore)(nil).GetUserShellToolDisplayMode), ctx, userID)
}
// GetUserStatusCounts mocks base method.
func (m *MockStore) GetUserStatusCounts(ctx context.Context, arg database.GetUserStatusCountsParams) ([]database.GetUserStatusCountsRow, error) {
m.ctrl.T.Helper()
@@ -9708,6 +9723,21 @@ func (mr *MockStoreMockRecorder) UpdateUserSecretByUserIDAndName(ctx, arg any) *
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateUserSecretByUserIDAndName", reflect.TypeOf((*MockStore)(nil).UpdateUserSecretByUserIDAndName), ctx, arg)
}
// UpdateUserShellToolDisplayMode mocks base method.
func (m *MockStore) UpdateUserShellToolDisplayMode(ctx context.Context, arg database.UpdateUserShellToolDisplayModeParams) (string, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "UpdateUserShellToolDisplayMode", ctx, arg)
ret0, _ := ret[0].(string)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// UpdateUserShellToolDisplayMode indicates an expected call of UpdateUserShellToolDisplayMode.
func (mr *MockStoreMockRecorder) UpdateUserShellToolDisplayMode(ctx, arg any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateUserShellToolDisplayMode", reflect.TypeOf((*MockStore)(nil).UpdateUserShellToolDisplayMode), ctx, arg)
}
// UpdateUserStatus mocks base method.
func (m *MockStore) UpdateUserStatus(ctx context.Context, arg database.UpdateUserStatusParams) (database.User, error) {
m.ctrl.T.Helper()
+2
View File
@@ -769,6 +769,7 @@ type sqlcQuerier interface {
// percentile_disc returns an actual integer count from the underlying
// values rather than interpolating between rows.
GetUserSecretsTelemetrySummary(ctx context.Context) (GetUserSecretsTelemetrySummaryRow, error)
GetUserShellToolDisplayMode(ctx context.Context, userID uuid.UUID) (string, error)
// GetUserStatusCounts returns the count of users in each status over time.
// The time range is inclusively defined by the start_time and end_time parameters.
GetUserStatusCounts(ctx context.Context, arg GetUserStatusCountsParams) ([]GetUserStatusCountsRow, error)
@@ -1223,6 +1224,7 @@ type sqlcQuerier interface {
UpdateUserQuietHoursSchedule(ctx context.Context, arg UpdateUserQuietHoursScheduleParams) (User, error)
UpdateUserRoles(ctx context.Context, arg UpdateUserRolesParams) (User, error)
UpdateUserSecretByUserIDAndName(ctx context.Context, arg UpdateUserSecretByUserIDAndNameParams) (UserSecret, error)
UpdateUserShellToolDisplayMode(ctx context.Context, arg UpdateUserShellToolDisplayModeParams) (string, error)
UpdateUserStatus(ctx context.Context, arg UpdateUserStatusParams) (User, error)
UpdateUserTaskNotificationAlertDismissed(ctx context.Context, arg UpdateUserTaskNotificationAlertDismissedParams) (bool, error)
UpdateUserTerminalFont(ctx context.Context, arg UpdateUserTerminalFontParams) (UserConfig, error)
+44
View File
@@ -25953,6 +25953,23 @@ func (q *sqlQuerier) GetUserCount(ctx context.Context, includeSystem bool) (int6
return count, err
}
const getUserShellToolDisplayMode = `-- name: GetUserShellToolDisplayMode :one
SELECT
value AS shell_tool_display_mode
FROM
user_configs
WHERE
user_id = $1
AND key = 'preference_shell_tool_display_mode'
`
func (q *sqlQuerier) GetUserShellToolDisplayMode(ctx context.Context, userID uuid.UUID) (string, error) {
row := q.db.QueryRowContext(ctx, getUserShellToolDisplayMode, userID)
var shell_tool_display_mode string
err := row.Scan(&shell_tool_display_mode)
return shell_tool_display_mode, err
}
const getUserTaskNotificationAlertDismissed = `-- name: GetUserTaskNotificationAlertDismissed :one
SELECT
value::boolean as task_notification_alert_dismissed
@@ -26869,6 +26886,33 @@ func (q *sqlQuerier) UpdateUserRoles(ctx context.Context, arg UpdateUserRolesPar
return i, err
}
const updateUserShellToolDisplayMode = `-- name: UpdateUserShellToolDisplayMode :one
INSERT INTO
user_configs (user_id, key, value)
VALUES
($1, 'preference_shell_tool_display_mode', $2::text)
ON CONFLICT
ON CONSTRAINT user_configs_pkey
DO UPDATE
SET
value = $2
WHERE user_configs.user_id = $1
AND user_configs.key = 'preference_shell_tool_display_mode'
RETURNING value AS shell_tool_display_mode
`
type UpdateUserShellToolDisplayModeParams struct {
UserID uuid.UUID `db:"user_id" json:"user_id"`
ShellToolDisplayMode string `db:"shell_tool_display_mode" json:"shell_tool_display_mode"`
}
func (q *sqlQuerier) UpdateUserShellToolDisplayMode(ctx context.Context, arg UpdateUserShellToolDisplayModeParams) (string, error) {
row := q.db.QueryRowContext(ctx, updateUserShellToolDisplayMode, arg.UserID, arg.ShellToolDisplayMode)
var shell_tool_display_mode string
err := row.Scan(&shell_tool_display_mode)
return shell_tool_display_mode, err
}
const updateUserStatus = `-- name: UpdateUserStatus :one
UPDATE
users
+23
View File
@@ -347,6 +347,29 @@ WHERE user_configs.user_id = @user_id
AND user_configs.key = 'preference_thinking_display_mode'
RETURNING value AS thinking_display_mode;
-- name: GetUserShellToolDisplayMode :one
SELECT
value AS shell_tool_display_mode
FROM
user_configs
WHERE
user_id = @user_id
AND key = 'preference_shell_tool_display_mode';
-- name: UpdateUserShellToolDisplayMode :one
INSERT INTO
user_configs (user_id, key, value)
VALUES
(@user_id, 'preference_shell_tool_display_mode', @shell_tool_display_mode::text)
ON CONFLICT
ON CONSTRAINT user_configs_pkey
DO UPDATE
SET
value = @shell_tool_display_mode
WHERE user_configs.user_id = @user_id
AND user_configs.key = 'preference_shell_tool_display_mode'
RETURNING value AS shell_tool_display_mode;
-- name: GetUserCodeDiffDisplayMode :one
SELECT
value AS code_diff_display_mode
+37
View File
@@ -1306,6 +1306,15 @@ func (api *API) userPreferenceSettings(rw http.ResponseWriter, r *http.Request)
return
}
shellToolMode, err := api.Database.GetUserShellToolDisplayMode(ctx, user.ID)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Error reading user preference settings.",
Detail: err.Error(),
})
return
}
codeDiffMode, err := api.Database.GetUserCodeDiffDisplayMode(ctx, user.ID)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
@@ -1327,6 +1336,7 @@ func (api *API) userPreferenceSettings(rw http.ResponseWriter, r *http.Request)
httpapi.Write(ctx, rw, http.StatusOK, codersdk.UserPreferenceSettings{
TaskNotificationAlertDismissed: taskAlertDismissed,
ThinkingDisplayMode: sanitizeThinkingDisplayMode(thinkingMode),
ShellToolDisplayMode: sanitizeAgentDisplayMode(shellToolMode),
CodeDiffDisplayMode: sanitizeAgentDisplayMode(codeDiffMode),
AgentChatSendShortcut: sanitizeAgentChatSendShortcut(agentChatSendShortcut),
})
@@ -1363,6 +1373,16 @@ func (api *API) putUserPreferenceSettings(rw http.ResponseWriter, r *http.Reques
})
return
}
if params.ShellToolDisplayMode != "" &&
!slices.Contains(codersdk.ValidAgentDisplayModes, params.ShellToolDisplayMode) {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Invalid shell tool display mode.",
Validations: []codersdk.ValidationError{
{Field: "shell_tool_display_mode", Detail: agentDisplayModeValidationDetail},
},
})
return
}
if params.CodeDiffDisplayMode != "" &&
!slices.Contains(codersdk.ValidAgentDisplayModes, params.CodeDiffDisplayMode) {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
@@ -1418,6 +1438,23 @@ func (api *API) putUserPreferenceSettings(rw http.ResponseWriter, r *http.Reques
settings.ThinkingDisplayMode = sanitizeThinkingDisplayMode(stored)
}
if params.ShellToolDisplayMode != "" {
updated, err := tx.UpdateUserShellToolDisplayMode(ctx, database.UpdateUserShellToolDisplayModeParams{
UserID: user.ID,
ShellToolDisplayMode: string(params.ShellToolDisplayMode),
})
if err != nil {
return newUserPreferenceSettingsAPIError("Internal error updating shell tool display mode.", err)
}
settings.ShellToolDisplayMode = sanitizeAgentDisplayMode(updated)
} else {
stored, err := tx.GetUserShellToolDisplayMode(ctx, user.ID)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return newUserPreferenceSettingsAPIError("Error reading shell tool display mode.", err)
}
settings.ShellToolDisplayMode = sanitizeAgentDisplayMode(stored)
}
if params.CodeDiffDisplayMode != "" {
updated, err := tx.UpdateUserCodeDiffDisplayMode(ctx, database.UpdateUserCodeDiffDisplayModeParams{
UserID: user.ID,
+63 -5
View File
@@ -2433,9 +2433,34 @@ func TestAgentDisplayModePreferences(t *testing.T) {
settings, err := client.GetUserPreferenceSettings(ctx, codersdk.Me)
require.NoError(t, err)
require.Equal(t, codersdk.AgentDisplayModeAuto, settings.ShellToolDisplayMode)
require.Equal(t, codersdk.AgentDisplayModeAuto, settings.CodeDiffDisplayMode)
})
t.Run("round-trips shell tool display mode", func(t *testing.T) {
t.Parallel()
client, _ := coderdtest.CreateAnotherUser(t, adminClient, firstUser.OrganizationID)
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort)
defer cancel()
for _, mode := range []codersdk.AgentDisplayMode{
codersdk.AgentDisplayModeAlwaysExpanded,
codersdk.AgentDisplayModeAlwaysCollapsed,
} {
updated, err := client.UpdateUserPreferenceSettings(ctx, codersdk.Me, codersdk.UpdateUserPreferenceSettingsRequest{
ShellToolDisplayMode: mode,
})
require.NoError(t, err)
require.Equal(t, mode, updated.ShellToolDisplayMode)
settings, err := client.GetUserPreferenceSettings(ctx, codersdk.Me)
require.NoError(t, err)
require.Equal(t, mode, settings.ShellToolDisplayMode)
}
})
t.Run("round-trips code diff display mode", func(t *testing.T) {
t.Parallel()
@@ -2469,24 +2494,57 @@ func TestAgentDisplayModePreferences(t *testing.T) {
defer cancel()
_, err := client.UpdateUserPreferenceSettings(ctx, codersdk.Me, codersdk.UpdateUserPreferenceSettingsRequest{
ThinkingDisplayMode: codersdk.ThinkingDisplayModePreview,
CodeDiffDisplayMode: codersdk.AgentDisplayModeAlwaysExpanded,
ThinkingDisplayMode: codersdk.ThinkingDisplayModePreview,
ShellToolDisplayMode: codersdk.AgentDisplayModeAlwaysCollapsed,
CodeDiffDisplayMode: codersdk.AgentDisplayModeAlwaysExpanded,
})
require.NoError(t, err)
updated, err := client.UpdateUserPreferenceSettings(ctx, codersdk.Me, codersdk.UpdateUserPreferenceSettingsRequest{
ThinkingDisplayMode: codersdk.ThinkingDisplayModeAlwaysExpanded,
ShellToolDisplayMode: codersdk.AgentDisplayModeAlwaysExpanded,
})
require.NoError(t, err)
require.Equal(t, codersdk.ThinkingDisplayModeAlwaysExpanded, updated.ThinkingDisplayMode)
require.Equal(t, codersdk.ThinkingDisplayModePreview, updated.ThinkingDisplayMode)
require.Equal(t, codersdk.AgentDisplayModeAlwaysExpanded, updated.ShellToolDisplayMode)
require.Equal(t, codersdk.AgentDisplayModeAlwaysExpanded, updated.CodeDiffDisplayMode)
settings, err := client.GetUserPreferenceSettings(ctx, codersdk.Me)
require.NoError(t, err)
require.Equal(t, codersdk.ThinkingDisplayModeAlwaysExpanded, settings.ThinkingDisplayMode)
require.Equal(t, codersdk.ThinkingDisplayModePreview, settings.ThinkingDisplayMode)
require.Equal(t, codersdk.AgentDisplayModeAlwaysExpanded, settings.ShellToolDisplayMode)
require.Equal(t, codersdk.AgentDisplayModeAlwaysExpanded, settings.CodeDiffDisplayMode)
})
t.Run("rejects invalid shell tool display mode", func(t *testing.T) {
t.Parallel()
client, _ := coderdtest.CreateAnotherUser(t, adminClient, firstUser.OrganizationID)
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort)
defer cancel()
for _, tt := range []struct {
name string
mode codersdk.AgentDisplayMode
}{
{
name: "bogus",
mode: codersdk.AgentDisplayMode("bogus"),
},
{
name: "thinking preview",
mode: codersdk.AgentDisplayMode(codersdk.ThinkingDisplayModePreview),
},
} {
t.Run(tt.name, func(t *testing.T) {
_, err := client.UpdateUserPreferenceSettings(ctx, codersdk.Me, codersdk.UpdateUserPreferenceSettingsRequest{
ShellToolDisplayMode: tt.mode,
})
requireValidationField(t, err, "shell_tool_display_mode")
})
}
})
t.Run("rejects invalid code diff display mode", func(t *testing.T) {
t.Parallel()
+2
View File
@@ -305,6 +305,7 @@ type UpdateUserAppearanceSettingsRequest struct {
type UserPreferenceSettings struct {
TaskNotificationAlertDismissed bool `json:"task_notification_alert_dismissed"`
ThinkingDisplayMode ThinkingDisplayMode `json:"thinking_display_mode"`
ShellToolDisplayMode AgentDisplayMode `json:"shell_tool_display_mode"`
CodeDiffDisplayMode AgentDisplayMode `json:"code_diff_display_mode"`
AgentChatSendShortcut AgentChatSendShortcut `json:"agent_chat_send_shortcut"`
}
@@ -312,6 +313,7 @@ type UserPreferenceSettings struct {
type UpdateUserPreferenceSettingsRequest struct {
TaskNotificationAlertDismissed *bool `json:"task_notification_alert_dismissed,omitempty"`
ThinkingDisplayMode ThinkingDisplayMode `json:"thinking_display_mode,omitempty"`
ShellToolDisplayMode AgentDisplayMode `json:"shell_tool_display_mode,omitempty"`
CodeDiffDisplayMode AgentDisplayMode `json:"code_diff_display_mode,omitempty"`
AgentChatSendShortcut AgentChatSendShortcut `json:"agent_chat_send_shortcut,omitempty"`
}
+4
View File
@@ -12960,6 +12960,7 @@ Restarts will only happen on weekdays in this list on weeks which line up with W
{
"agent_chat_send_shortcut": "enter",
"code_diff_display_mode": "auto",
"shell_tool_display_mode": "auto",
"task_notification_alert_dismissed": true,
"thinking_display_mode": "auto"
}
@@ -12971,6 +12972,7 @@ Restarts will only happen on weekdays in this list on weeks which line up with W
|-------------------------------------|------------------------------------------------------------------|----------|--------------|-------------|
| `agent_chat_send_shortcut` | [codersdk.AgentChatSendShortcut](#codersdkagentchatsendshortcut) | false | | |
| `code_diff_display_mode` | [codersdk.AgentDisplayMode](#codersdkagentdisplaymode) | false | | |
| `shell_tool_display_mode` | [codersdk.AgentDisplayMode](#codersdkagentdisplaymode) | false | | |
| `task_notification_alert_dismissed` | boolean | false | | |
| `thinking_display_mode` | [codersdk.ThinkingDisplayMode](#codersdkthinkingdisplaymode) | false | | |
@@ -13556,6 +13558,7 @@ If the schedule is empty, the user will be updated to use the default schedule.|
{
"agent_chat_send_shortcut": "enter",
"code_diff_display_mode": "auto",
"shell_tool_display_mode": "auto",
"task_notification_alert_dismissed": true,
"thinking_display_mode": "auto"
}
@@ -13567,6 +13570,7 @@ If the schedule is empty, the user will be updated to use the default schedule.|
|-------------------------------------|------------------------------------------------------------------|----------|--------------|-------------|
| `agent_chat_send_shortcut` | [codersdk.AgentChatSendShortcut](#codersdkagentchatsendshortcut) | false | | |
| `code_diff_display_mode` | [codersdk.AgentDisplayMode](#codersdkagentdisplaymode) | false | | |
| `shell_tool_display_mode` | [codersdk.AgentDisplayMode](#codersdkagentdisplaymode) | false | | |
| `task_notification_alert_dismissed` | boolean | false | | |
| `thinking_display_mode` | [codersdk.ThinkingDisplayMode](#codersdkthinkingdisplaymode) | false | | |
+3
View File
@@ -1312,6 +1312,7 @@ curl -X GET http://coder-server:8080/api/v2/users/{user}/preferences \
{
"agent_chat_send_shortcut": "enter",
"code_diff_display_mode": "auto",
"shell_tool_display_mode": "auto",
"task_notification_alert_dismissed": true,
"thinking_display_mode": "auto"
}
@@ -1345,6 +1346,7 @@ curl -X PUT http://coder-server:8080/api/v2/users/{user}/preferences \
{
"agent_chat_send_shortcut": "enter",
"code_diff_display_mode": "auto",
"shell_tool_display_mode": "auto",
"task_notification_alert_dismissed": true,
"thinking_display_mode": "auto"
}
@@ -1365,6 +1367,7 @@ curl -X PUT http://coder-server:8080/api/v2/users/{user}/preferences \
{
"agent_chat_send_shortcut": "enter",
"code_diff_display_mode": "auto",
"shell_tool_display_mode": "auto",
"task_notification_alert_dismissed": true,
"thinking_display_mode": "auto"
}
+2
View File
@@ -8507,6 +8507,7 @@ export interface UpdateUserPasswordRequest {
export interface UpdateUserPreferenceSettingsRequest {
readonly task_notification_alert_dismissed?: boolean;
readonly thinking_display_mode?: ThinkingDisplayMode;
readonly shell_tool_display_mode?: AgentDisplayMode;
readonly code_diff_display_mode?: AgentDisplayMode;
readonly agent_chat_send_shortcut?: AgentChatSendShortcut;
}
@@ -8903,6 +8904,7 @@ export interface UserParameter {
export interface UserPreferenceSettings {
readonly task_notification_alert_dismissed: boolean;
readonly thinking_display_mode: ThinkingDisplayMode;
readonly shell_tool_display_mode: AgentDisplayMode;
readonly code_diff_display_mode: AgentDisplayMode;
readonly agent_chat_send_shortcut: AgentChatSendShortcut;
}
@@ -10,6 +10,7 @@ import {
const preferencesData = {
task_notification_alert_dismissed: false,
thinking_display_mode: "auto" as const,
shell_tool_display_mode: "auto" as const,
code_diff_display_mode: "auto" as const,
agent_chat_send_shortcut: "enter" as const,
};
@@ -177,6 +178,7 @@ export const RendersAgentDisplayModeSettings: Story = {
const canvas = within(canvasElement);
expect(await canvas.findByText("Thinking Display")).toBeVisible();
expect(await canvas.findByText("Shell Output Display")).toBeVisible();
expect(await canvas.findByText("Code Diff Display")).toBeVisible();
},
};
@@ -5,6 +5,7 @@ import { ChatFullWidthSettings } from "./components/ChatFullWidthSettings";
import { ChatSendShortcutSettings } from "./components/ChatSendShortcutSettings";
import {
CodeDiffDisplaySettings,
ShellToolDisplaySettings,
ThinkingDisplaySettings,
} from "./components/DisplayModeSettings";
import { PersonalInstructionsSettings } from "./components/PersonalInstructionsSettings";
@@ -60,6 +61,7 @@ export const AgentSettingsGeneralPageView: FC<
<ChatFullWidthSettings />
<ChatSendShortcutSettings />
<ThinkingDisplaySettings />
<ShellToolDisplaySettings />
<CodeDiffDisplaySettings />
<UserChatDebugLoggingSettings
userSettings={userDebugLoggingData}
@@ -1841,7 +1841,7 @@ export const AssistantActionBarAfterHiddenMessages: Story = {
},
};
export const CodeDiffDisplayModeFromPreferences: Story = {
export const ToolDisplayModesFromPreferences: Story = {
parameters: {
queries: [
{
@@ -1849,6 +1849,7 @@ export const CodeDiffDisplayModeFromPreferences: Story = {
data: {
task_notification_alert_dismissed: false,
thinking_display_mode: "auto" as const,
shell_tool_display_mode: "always_collapsed" as const,
code_diff_display_mode: "always_collapsed" as const,
agent_chat_send_shortcut: "enter" as const,
},
@@ -1871,6 +1872,14 @@ export const CodeDiffDisplayModeFromPreferences: Story = {
toolCalls: [],
toolResults: [],
tools: [
{
id: "execute-tool",
name: "execute",
args: { command: "pnpm test" },
result: { output: "tests passed" },
isError: false,
status: "completed",
},
{
id: "edit-tool",
name: "edit_files",
@@ -1892,7 +1901,10 @@ export const CodeDiffDisplayModeFromPreferences: Story = {
status: "completed",
},
],
blocks: [{ type: "tool", id: "edit-tool" }],
blocks: [
{ type: "tool", id: "execute-tool" },
{ type: "tool", id: "edit-tool" },
],
sources: [],
},
},
@@ -1900,9 +1912,20 @@ export const CodeDiffDisplayModeFromPreferences: Story = {
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(canvas.getByText("pnpm test")).toBeVisible();
expect(canvas.queryByText("tests passed")).not.toBeInTheDocument();
expect(canvas.getByText(/Edited config\.ts/)).toBeVisible();
expect(canvas.queryAllByTestId("edit-file-diff")).toHaveLength(0);
const commandOutputButton = canvas.getByRole("button", {
name: "Expand command output",
});
expect(commandOutputButton).toHaveAttribute("aria-expanded", "false");
await userEvent.click(commandOutputButton);
await waitFor(() => {
expect(canvas.getByText("tests passed")).toBeVisible();
});
const editFilesButton = canvas.getByRole("button", {
name: /Edited config\.ts/,
});
@@ -1926,6 +1949,7 @@ export const ThinkingBlockAlwaysExpanded: Story = {
data: {
task_notification_alert_dismissed: false,
thinking_display_mode: "always_expanded" as const,
shell_tool_display_mode: "auto" as const,
code_diff_display_mode: "auto" as const,
agent_chat_send_shortcut: "enter" as const,
},
@@ -1975,6 +1999,7 @@ export const ThinkingBlockAlwaysCollapsed: Story = {
data: {
task_notification_alert_dismissed: false,
thinking_display_mode: "always_collapsed" as const,
shell_tool_display_mode: "auto" as const,
code_diff_display_mode: "auto" as const,
agent_chat_send_shortcut: "enter" as const,
},
@@ -2025,6 +2050,7 @@ export const ThinkingBlockWithToolCall: Story = {
data: {
task_notification_alert_dismissed: false,
thinking_display_mode: "always_collapsed" as const,
shell_tool_display_mode: "auto" as const,
code_diff_display_mode: "auto" as const,
agent_chat_send_shortcut: "enter" as const,
},
@@ -2088,6 +2114,7 @@ export const ThinkingBlockAutoMode: Story = {
data: {
task_notification_alert_dismissed: false,
thinking_display_mode: "auto" as const,
shell_tool_display_mode: "auto" as const,
code_diff_display_mode: "auto" as const,
agent_chat_send_shortcut: "enter" as const,
},
@@ -2141,6 +2168,7 @@ export const ThinkingBlockPreviewMode: Story = {
data: {
task_notification_alert_dismissed: false,
thinking_display_mode: "preview" as const,
shell_tool_display_mode: "auto" as const,
code_diff_display_mode: "auto" as const,
agent_chat_send_shortcut: "enter" as const,
},
@@ -275,6 +275,8 @@ export const BlockList: FC<{
const prefQuery = useQuery(preferenceSettings());
const thinkingDisplayMode: ThinkingDisplayMode =
prefQuery.data?.thinking_display_mode || "auto";
const shellToolDisplayMode: TypesGen.AgentDisplayMode =
prefQuery.data?.shell_tool_display_mode || "auto";
const codeDiffDisplayMode: TypesGen.AgentDisplayMode =
prefQuery.data?.code_diff_display_mode || "auto";
@@ -367,6 +369,7 @@ export const BlockList: FC<{
name="Tool"
status="running"
isError={false}
shellToolDisplayMode={shellToolDisplayMode}
codeDiffDisplayMode={codeDiffDisplayMode}
subagentTitles={subagentTitles}
subagentVariants={subagentVariants}
@@ -384,6 +387,7 @@ export const BlockList: FC<{
status={tool.status}
isError={tool.isError}
killedBySignal={tool.killedBySignal}
shellToolDisplayMode={shellToolDisplayMode}
codeDiffDisplayMode={codeDiffDisplayMode}
subagentTitles={subagentTitles}
subagentVariants={subagentVariants}
@@ -440,6 +444,7 @@ export const BlockList: FC<{
status={tool.status}
isError={tool.isError}
killedBySignal={tool.killedBySignal}
shellToolDisplayMode={shellToolDisplayMode}
codeDiffDisplayMode={codeDiffDisplayMode}
subagentTitles={subagentTitles}
subagentVariants={subagentVariants}
@@ -564,7 +569,8 @@ const ChatMessageItem = memo<{
) : (
<Message className="w-full">
<MessageContent className="whitespace-normal">
<div className="relative space-y-3 overflow-visible">
{/* Keep consecutive shell tools tighter because execute/process_output pairs read as one terminal interaction. */}
<div className="relative space-y-3 overflow-visible [&>[data-shell-tool]+[data-shell-tool]]:mt-2">
<BlockList
blocks={parsed.blocks}
tools={parsed.tools}
@@ -1,7 +1,10 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { userEvent, within } from "storybook/test";
import { expect, screen, userEvent, within } from "storybook/test";
import { ExecuteTool } from "./ExecuteTool";
const longCommand =
"find /home/coder/project/src -type f -name '*.ts' -not -path '*/node_modules/*' -not -path '*/.git/*' | xargs grep -l 'deprecated' | sort | head -50";
const meta: Meta<typeof ExecuteTool> = {
title: "components/ai-elements/tool/ExecuteTool",
component: ExecuteTool,
@@ -29,29 +32,29 @@ export const ShortCommand: Story = {
},
};
/** A long command expanded to show the full text, with the chevron visible on hover. */
export const LongCommand: Story = {
args: {
command:
"find /home/coder/project/src -type f -name '*.ts' -not -path '*/node_modules/*' -not -path '*/.git/*' | xargs grep -l 'deprecated' | sort | head -50",
command: longCommand,
output: "",
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const chevron = canvas.getByRole("button", {
name: /expand command/i,
});
await userEvent.click(chevron);
// Hover the component so the chevron stays visible.
await userEvent.hover(canvasElement.firstElementChild!);
const command = canvas.getByText(longCommand);
expect(command).toBeVisible();
expect(
canvas.queryByRole("button", { name: longCommand }),
).not.toBeInTheDocument();
await userEvent.hover(command);
expect(
await screen.findByRole("tooltip", undefined, { timeout: 2000 }),
).toHaveTextContent(longCommand);
},
};
/** A long truncated command with multi-line output below it. */
export const LongCommandWithOutput: Story = {
args: {
command:
"find /home/coder/project/src -type f -name '*.ts' -not -path '*/node_modules/*' -not -path '*/.git/*' | xargs grep -l 'deprecated' | sort | head -50",
command: longCommand,
output: [
"src/api/legacyClient.ts",
"src/components/OldTable/OldTable.tsx",
@@ -9,7 +9,8 @@ import {
TriangleAlertIcon,
} from "lucide-react";
import type React from "react";
import { useRef, useState } from "react";
import { useState } from "react";
import type * as TypesGen from "#/api/typesGenerated";
import { Button } from "#/components/Button/Button";
import { CopyButton } from "#/components/CopyButton/CopyButton";
import { ScrollArea } from "#/components/ScrollArea/ScrollArea";
@@ -20,186 +21,210 @@ import {
} from "#/components/Tooltip/Tooltip";
import { cn } from "#/utils/cn";
import {
BORDER_BG_STYLE,
COLLAPSED_OUTPUT_HEIGHT,
type AgentDisplayState,
isAgentDisplayOpen,
resolveAgentDisplayState,
} from "./displayMode";
import {
formatShellDurationMs,
signalTooltipLabel,
type ToolStatus,
} from "./utils";
/**
* Specialized rendering for `execute` tool calls. Shows the command
* in a terminal-style block with a copy button. Output is shown in a
* collapsed preview (~3 lines) with an expand chevron at the bottom.
*/
export const ExecuteTool: React.FC<{
type ExecuteToolProps = {
command: string;
output: string;
status: ToolStatus;
isError: boolean;
durationMs?: number;
isBackgrounded?: boolean;
killedBySignal?: "kill" | "terminate";
}> = ({ command, output, status, isBackgrounded = false, killedBySignal }) => {
const [expanded, setExpanded] = useState(false);
const outputRef = useRef<HTMLPreElement | null>(null);
shellToolDisplayMode?: TypesGen.AgentDisplayMode;
};
type ExecuteToolInnerProps = ExecuteToolProps & {
outputInitiallyOpen: boolean;
};
export const ExecuteTool: React.FC<ExecuteToolProps> = (props) => {
const autoDisplayState: AgentDisplayState =
props.output.length > 0 ||
props.status === "running" ||
props.isBackgrounded ||
!!props.killedBySignal
? "preview"
: "collapsed";
const resolvedDisplayState = resolveAgentDisplayState(
props.shellToolDisplayMode,
autoDisplayState,
);
return (
<ExecuteToolInner
key={`${props.shellToolDisplayMode ?? "auto"}:${autoDisplayState}`}
{...props}
outputInitiallyOpen={isAgentDisplayOpen(resolvedDisplayState)}
/>
);
};
const ExecuteToolInner: React.FC<ExecuteToolInnerProps> = ({
command,
output,
status,
isError,
durationMs,
isBackgrounded = false,
killedBySignal,
outputInitiallyOpen,
}) => {
const hasOutput = output.length > 0;
const isRunning = status === "running";
// Track whether the command text is truncated so we can offer
// a click-to-expand interaction. The ResizeObserver may clear
// commandOverflows while the text is wrapped, but
// canToggleCommand stays true via commandExpanded so the
// collapse affordance remains visible.
const [commandExpanded, setCommandExpanded] = useState(false);
const [commandOverflows, setCommandOverflows] = useState(false);
const canToggleCommand = commandOverflows || commandExpanded;
const commandRef = (node: HTMLElement | null) => {
if (!node) return;
const measure = () => {
setCommandOverflows(node.scrollWidth > node.clientWidth);
};
measure();
const ro = new ResizeObserver(measure);
ro.observe(node);
return () => ro.disconnect();
};
// Check whether the output overflows the collapsed height so we
// know if we need to show the expand toggle at all.
const [overflows, setOverflows] = useState(false);
const measureRef = (node: HTMLPreElement | null) => {
outputRef.current = node;
if (node) {
setOverflows(node.scrollHeight > COLLAPSED_OUTPUT_HEIGHT);
}
};
const showFailureIndicator = isError && !isRunning;
const [outputOpen, setOutputOpen] = useState(outputInitiallyOpen);
const outputToggleLabel = outputOpen
? "Collapse command output"
: "Expand command output";
const durationLabel = formatShellDurationMs(durationMs);
return (
<div className="group/exec w-full overflow-hidden rounded-md border border-solid border-border-default bg-surface-primary">
{/* Header: $ command + copy button */}
<div className="flex w-full items-start justify-between gap-2 px-3 py-2">
{/* biome-ignore lint/a11y/useKeyWithClickEvents: Click toggles for mouse users; keyboard users use the chevron button. */}
<div
className={cn(
"flex min-w-0 flex-1 items-start gap-2",
canToggleCommand && "cursor-pointer",
)}
onClick={
canToggleCommand ? () => setCommandExpanded((v) => !v) : undefined
}
>
<span className="shrink-0 font-mono text-xs leading-5 text-content-secondary">
$
</span>
<code
ref={commandRef}
className={cn(
"min-w-0 flex-1 font-mono text-xs leading-5 text-content-primary",
commandExpanded ? "whitespace-pre-wrap break-all" : "truncate",
)}
>
{command}
</code>
</div>
<div className="flex shrink-0 items-center gap-1">
{canToggleCommand && (
<div className="group/exec grid w-full grid-cols-[minmax(0,1fr)_auto] items-start gap-x-2 rounded-md bg-surface-primary font-mono text-xs leading-5">
<Tooltip delayDuration={300}>
<TooltipTrigger asChild>
{hasOutput ? (
<button
type="button"
onClick={() => setCommandExpanded((v) => !v)}
className={cn(
"border-0 bg-transparent p-0 m-0 cursor-pointer flex items-center text-content-secondary hover:text-content-primary transition-colors transition-opacity focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-content-link",
commandExpanded
? "opacity-100"
: "opacity-0 group-hover/exec:opacity-100",
)}
aria-expanded={commandExpanded}
aria-label={
commandExpanded ? "Collapse command" : "Expand command"
}
aria-expanded={outputOpen}
aria-label={outputToggleLabel}
onClick={() => setOutputOpen((value) => !value)}
className="col-start-1 row-start-1 m-0 flex w-full min-w-0 cursor-pointer items-center gap-2 border-0 bg-transparent p-0 text-left font-[inherit] text-[inherit] text-content-secondary transition-colors hover:text-content-primary"
>
<ChevronDownIcon
className={cn(
"h-3.5 w-3.5 transition-transform",
commandExpanded && "rotate-180",
)}
<ShellCommandLine
command={command}
durationLabel={durationLabel}
expanded={outputOpen}
/>
</button>
) : (
<div className="col-start-1 row-start-1 flex min-w-0 items-center gap-2 text-content-secondary">
<ShellCommandLine
command={command}
durationLabel={durationLabel}
/>
</div>
)}
{isRunning && (
<LoaderIcon className="h-3.5 w-3.5 shrink-0 animate-spin motion-reduce:animate-none text-content-secondary" />
)}
{isBackgrounded && !isRunning && (
<Tooltip>
<TooltipTrigger asChild>
<LayersIcon className="h-3.5 w-3.5 shrink-0 text-content-secondary" />
</TooltipTrigger>
<TooltipContent>Running in background</TooltipContent>
</Tooltip>
)}
{killedBySignal && !isRunning && (
<Tooltip>
<TooltipTrigger asChild>
<OctagonXIcon className="size-3.5 shrink-0 text-content-secondary" />
</TooltipTrigger>
<TooltipContent>
{signalTooltipLabel(killedBySignal)}
</TooltipContent>
</Tooltip>
)}
<CopyButton
text={command}
label="Copy command"
className="-my-0.5 size-6 p-0 opacity-0 transition-opacity hover:bg-surface-tertiary group-hover/exec:opacity-100"
/>
</div>
</TooltipTrigger>
<TooltipContent className="max-w-xl whitespace-pre-wrap break-all font-mono">
{command}
</TooltipContent>
</Tooltip>
<div className="col-start-2 row-start-1 flex shrink-0 items-center gap-1">
{isRunning && (
<LoaderIcon className="h-3.5 w-3.5 shrink-0 animate-spin motion-reduce:animate-none text-content-secondary" />
)}
{showFailureIndicator && (
<Tooltip>
<TooltipTrigger asChild>
<span
aria-label="Command failed"
role="img"
className="flex shrink-0 text-content-destructive"
>
<TriangleAlertIcon
aria-hidden
className="h-3.5 w-3.5 shrink-0"
/>
</span>
</TooltipTrigger>
<TooltipContent>Command failed</TooltipContent>
</Tooltip>
)}
{isBackgrounded && !isRunning && (
<Tooltip>
<TooltipTrigger asChild>
<span
aria-label="Running in background"
role="img"
className="flex shrink-0 text-content-secondary"
>
<LayersIcon aria-hidden className="h-3.5 w-3.5 shrink-0" />
</span>
</TooltipTrigger>
<TooltipContent>Running in background</TooltipContent>
</Tooltip>
)}
{killedBySignal && !isRunning && (
<Tooltip>
<TooltipTrigger asChild>
<OctagonXIcon className="size-3.5 shrink-0 text-content-secondary" />
</TooltipTrigger>
<TooltipContent>
{signalTooltipLabel(killedBySignal)}
</TooltipContent>
</Tooltip>
)}
<CopyButton
text={command}
label="Copy command"
className="-my-0.5 size-6 p-0 opacity-0 transition-opacity hover:bg-surface-tertiary group-hover/exec:opacity-100"
/>
</div>
{/* Output preview / expanded */}
{hasOutput && (
<>
<div className="h-px" style={BORDER_BG_STYLE} />
<ScrollArea
className="text-2xs"
viewportClassName={expanded ? "max-h-96" : ""}
scrollBarClassName="w-1.5"
>
<pre
ref={measureRef}
style={
expanded
? undefined
: { maxHeight: COLLAPSED_OUTPUT_HEIGHT, overflow: "hidden" }
}
className={cn(
"m-0 border-0 whitespace-pre-wrap break-all bg-transparent px-3 py-2 font-mono text-xs",
"text-content-secondary",
)}
>
{output}
</pre>
</ScrollArea>
{/* Expand / collapse toggle at the bottom */}
{overflows && (
<button
type="button"
aria-expanded={expanded}
onClick={() => setExpanded((v) => !v)}
className="border-0 bg-transparent m-0 font-[inherit] text-[inherit] flex w-full cursor-pointer items-center justify-center py-0.5 text-content-secondary transition-colors hover:bg-surface-secondary hover:text-content-primary"
aria-label={expanded ? "Collapse output" : "Expand output"}
>
<ChevronDownIcon
className={cn(
"h-3 w-3 transition-transform",
expanded && "rotate-180",
)}
/>
</button>
)}
</>
{hasOutput && outputOpen && (
<ShellOutputBody output={output} isError={isError} />
)}
</div>
);
};
const ShellCommandLine: React.FC<{
command: string;
durationLabel: string;
expanded?: boolean;
}> = ({ command, durationLabel, expanded }) => {
return (
<>
<span className="shrink-0 text-[13px] text-content-success">$</span>
<span className="block min-w-0 truncate text-[13px] text-content-primary">
{command}
</span>
{durationLabel && (
<span className="shrink-0 text-[13px] text-content-secondary">
{durationLabel}
</span>
)}
{expanded !== undefined && (
<ChevronDownIcon
className={cn(
"h-3 w-3 shrink-0 text-current transition-transform",
expanded ? "rotate-0" : "-rotate-90",
)}
/>
)}
</>
);
};
const ShellOutputBody: React.FC<{
output: string;
isError: boolean;
}> = ({ output, isError }) => {
return (
<ScrollArea
className="col-start-1 col-span-2 mt-1 rounded-md border border-solid border-border-default/50 bg-surface-secondary/30 text-2xs"
viewportClassName="max-h-64"
scrollBarClassName="w-1.5"
>
<pre
className={cn(
"m-0 whitespace-pre-wrap break-all border-0 bg-transparent px-2 py-1.5 font-mono text-xs leading-5",
isError ? "text-content-destructive" : "text-content-secondary",
)}
>
{output}
</pre>
</ScrollArea>
);
};
export const ExecuteAuthRequiredTool: React.FC<{
command: string;
output: string;
@@ -19,7 +19,7 @@ export default meta;
type Story = StoryObj<typeof Tool>;
// ---------------------------------------------------------------------------
// Execute tool killed indicator via killedBySignal prop
// Execute tool, killed indicator via killedBySignal prop.
// ---------------------------------------------------------------------------
export const ExecuteKilled: Story = {
@@ -62,7 +62,7 @@ export const ExecuteTerminated: Story = {
},
};
/** Execute NOT signaled no indicator. */
/** Execute not signaled, no indicator. */
export const ExecuteNotSignaled: Story = {
args: {
name: "execute",
@@ -81,7 +81,7 @@ export const ExecuteNotSignaled: Story = {
},
};
/** Running execute killed indicator should NOT appear yet. */
/** Running execute, killed indicator should not appear yet. */
export const ExecuteRunningNotYetKilled: Story = {
args: {
name: "execute",
@@ -96,7 +96,7 @@ export const ExecuteRunningNotYetKilled: Story = {
};
// ---------------------------------------------------------------------------
// ProcessOutput tool killed indicator
// ProcessOutput tool, killed indicator.
// ---------------------------------------------------------------------------
export const ProcessOutputKilled: Story = {
@@ -133,7 +133,7 @@ export const ProcessOutputTerminated: Story = {
},
};
/** ProcessOutput with no output and killed indicator in empty state. */
/** ProcessOutput with no output and killed, indicator in empty state. */
export const ProcessOutputKilledNoOutput: Story = {
args: {
name: "process_output",
@@ -1,6 +1,7 @@
import { ChevronDownIcon, LoaderIcon, OctagonXIcon } from "lucide-react";
import type React from "react";
import { useRef, useState } from "react";
import { useState } from "react";
import type * as TypesGen from "#/api/typesGenerated";
import { CopyButton } from "#/components/CopyButton/CopyButton";
import { ScrollArea } from "#/components/ScrollArea/ScrollArea";
import {
@@ -9,124 +10,152 @@ import {
TooltipTrigger,
} from "#/components/Tooltip/Tooltip";
import { cn } from "#/utils/cn";
import {
type AgentDisplayState,
isAgentDisplayFullyExpanded,
resolveAgentDisplayState,
} from "./displayMode";
import { AgentDisplayModeToolCollapsible } from "./ToolCollapsible";
import { COLLAPSED_OUTPUT_HEIGHT, signalTooltipLabel } from "./utils";
/**
* Specialized rendering for `process_output` tool calls. Shows
* process output directly in a terminal-style block with a
* collapsible preview and an expand chevron at the bottom.
*/
export const ProcessOutputTool: React.FC<{
type ProcessOutputToolProps = {
output: string;
isRunning: boolean;
exitCode: number | null;
isError: boolean;
killedBySignal?: "kill" | "terminate";
}> = ({ output, isRunning, exitCode, isError, killedBySignal }) => {
const [expanded, setExpanded] = useState(false);
const outputRef = useRef<HTMLPreElement | null>(null);
shellToolDisplayMode?: TypesGen.AgentDisplayMode;
};
type ProcessOutputToolInnerProps = ProcessOutputToolProps & {
autoDisplayState: AgentDisplayState;
outputInitiallyFullyExpanded: boolean;
};
export const ProcessOutputTool: React.FC<ProcessOutputToolProps> = (props) => {
const autoDisplayState: AgentDisplayState =
props.output.length > 0 ? "preview" : "collapsed";
const resolvedDisplayState = resolveAgentDisplayState(
props.shellToolDisplayMode,
autoDisplayState,
);
return (
<ProcessOutputToolInner
key={`${props.shellToolDisplayMode ?? "auto"}:${autoDisplayState}`}
{...props}
autoDisplayState={autoDisplayState}
outputInitiallyFullyExpanded={isAgentDisplayFullyExpanded(
resolvedDisplayState,
)}
/>
);
};
const ProcessOutputToolInner: React.FC<ProcessOutputToolInnerProps> = ({
output,
isRunning,
exitCode,
isError,
killedBySignal,
shellToolDisplayMode,
autoDisplayState,
outputInitiallyFullyExpanded,
}) => {
const [outputFullyExpanded, setOutputFullyExpanded] = useState(
outputInitiallyFullyExpanded,
);
const hasOutput = output.length > 0;
const [overflows, setOverflows] = useState(false);
const measureRef = (node: HTMLPreElement | null) => {
outputRef.current = node;
if (node) {
setOverflows(node.scrollHeight > COLLAPSED_OUTPUT_HEIGHT);
}
};
const showExitCode = exitCode !== null && exitCode !== 0;
const toggleOutputExpansion = () => {
setOutputFullyExpanded((expanded) => !expanded);
};
const hasHeaderActions =
isRunning || Boolean(killedBySignal) || showExitCode || hasOutput;
return (
<div className="group/proc w-full overflow-hidden rounded-md border border-solid border-border-default bg-surface-primary">
{hasOutput ? (
<>
<div className="relative">
<ScrollArea
className="text-2xs"
viewportClassName={expanded ? "max-h-96" : ""}
scrollBarClassName="w-1.5"
>
<pre
ref={measureRef}
style={
expanded
? undefined
: { maxHeight: COLLAPSED_OUTPUT_HEIGHT, overflow: "hidden" }
}
className={cn(
"m-0 border-0 whitespace-pre-wrap break-all bg-transparent px-3 py-2 font-mono text-xs",
isError
? "text-content-destructive"
: "text-content-secondary",
)}
>
{output}
</pre>
</ScrollArea>
<div className="absolute right-1 top-0.5 flex items-center gap-1 opacity-0 transition-opacity group-hover/proc:opacity-100">
{isRunning && (
<LoaderIcon className="h-3.5 w-3.5 shrink-0 animate-spin motion-reduce:animate-none text-content-secondary" />
)}
{killedBySignal && !isRunning && (
<Tooltip>
<TooltipTrigger asChild>
<OctagonXIcon className="size-3.5 shrink-0 text-content-secondary" />
</TooltipTrigger>
<TooltipContent>
{signalTooltipLabel(killedBySignal)}
</TooltipContent>
</Tooltip>
)}
{showExitCode && (
<span className="rounded px-1.5 py-0.5 font-mono text-2xs leading-none bg-surface-red text-content-destructive">
exit {exitCode}
</span>
)}
<CopyButton text={output} label="Copy output" />
</div>
</div>
{/* Expand / collapse toggle at the bottom */}
{overflows && (
<button
type="button"
aria-expanded={expanded}
onClick={() => setExpanded((v) => !v)}
className="border-0 bg-transparent m-0 font-[inherit] text-[inherit] flex w-full cursor-pointer items-center justify-center py-0.5 text-content-secondary transition-colors hover:bg-surface-secondary hover:text-content-primary"
aria-label={expanded ? "Collapse output" : "Expand output"}
>
<ChevronDownIcon
className={cn(
"h-3 w-3 transition-transform",
expanded && "rotate-180",
)}
/>
</button>
<AgentDisplayModeToolCollapsible
className="group/proc w-full"
hasContent={hasOutput}
displayMode={shellToolDisplayMode}
autoDisplayState={autoDisplayState}
ariaLabel={(expanded) =>
expanded ? "Collapse process output" : "Expand process output"
}
header={<span className="text-[13px]">Process output</span>}
headerActions={
hasHeaderActions ? (
<>
{isRunning && (
<LoaderIcon className="h-3.5 w-3.5 shrink-0 animate-spin motion-reduce:animate-none text-content-secondary" />
)}
{killedBySignal && !isRunning && (
<Tooltip>
<TooltipTrigger asChild>
<OctagonXIcon className="size-3.5 shrink-0 text-content-secondary" />
</TooltipTrigger>
<TooltipContent>
{signalTooltipLabel(killedBySignal)}
</TooltipContent>
</Tooltip>
)}
{showExitCode && (
<span className="rounded px-1.5 py-0.5 font-mono text-2xs leading-none bg-surface-red text-content-destructive">
exit {exitCode}
</span>
)}
{hasOutput && <CopyButton text={output} label="Copy output" />}
</>
) : undefined
}
>
<ScrollArea
className="mt-1.5 rounded-md border border-solid border-border-default text-2xs"
viewportClassName={outputFullyExpanded ? "max-h-64" : ""}
scrollBarClassName="w-1.5"
>
<pre
ref={measureRef}
style={
outputFullyExpanded
? undefined
: { maxHeight: COLLAPSED_OUTPUT_HEIGHT, overflow: "hidden" }
}
className={cn(
"m-0 border-0 whitespace-pre-wrap break-all bg-transparent px-3 py-2 font-mono text-xs",
isError ? "text-content-destructive" : "text-content-secondary",
)}
</>
) : (
<div className="flex items-center gap-1 px-3 py-1.5">
{isRunning && (
<LoaderIcon className="h-3.5 w-3.5 shrink-0 animate-spin motion-reduce:animate-none text-content-secondary" />
)}
{killedBySignal && !isRunning && (
<Tooltip>
<TooltipTrigger asChild>
<OctagonXIcon className="size-3.5 shrink-0 text-content-secondary" />
</TooltipTrigger>
<TooltipContent>
{signalTooltipLabel(killedBySignal)}
</TooltipContent>
</Tooltip>
)}
{showExitCode && (
<span className="rounded px-1.5 py-0.5 font-mono text-2xs leading-none bg-surface-red text-content-destructive">
exit {exitCode}
</span>
)}
</div>
>
{output}
</pre>
</ScrollArea>
{overflows && (
<button
type="button"
aria-expanded={outputFullyExpanded}
onClick={toggleOutputExpansion}
className="border-0 bg-transparent m-0 mt-0.5 font-[inherit] text-[inherit] flex w-full cursor-pointer items-center justify-center rounded-md py-0.5 text-content-secondary transition-colors hover:bg-surface-secondary hover:text-content-primary"
aria-label={
outputFullyExpanded
? "Collapse full process output"
: "Expand full process output"
}
>
<ChevronDownIcon
className={cn(
"h-3 w-3 transition-transform",
outputFullyExpanded && "rotate-180",
)}
/>
</button>
)}
</div>
</AgentDisplayModeToolCollapsible>
);
};
@@ -1,11 +1,21 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, fn, spyOn, userEvent, waitFor, within } from "storybook/test";
import {
expect,
fn,
screen,
spyOn,
userEvent,
waitFor,
within,
} from "storybook/test";
import { reactRouterParameters } from "storybook-addon-remix-react-router";
import { ChatWorkspaceContext } from "../../../context/ChatWorkspaceContext";
import { DesktopPanelContext } from "./DesktopPanelContext";
import { Tool } from "./Tool";
const executeCommand = "git fetch origin";
const longExecuteCommand =
"docker build --no-cache --build-arg NODE_ENV=production --build-arg API_URL=https://coder.example.com/api --build-arg SENTRY_DSN=https://example.com/sentry --build-arg FEATURE_FLAGS=agents,shell-tools --tag coder-agent:latest .";
const meta: Meta<typeof Tool> = {
title: "pages/AgentsPage/ChatElements/tools/Tool",
component: Tool,
@@ -46,7 +56,11 @@ export const ExecuteRunning: Story = {
export const ExecuteSuccess: Story = {
args: {
shellToolDisplayMode: "auto",
args: { command: longExecuteCommand },
result: {
wall_duration_ms: 47200,
exit_code: 0,
output:
"From github.com:coder/coder\n * [new branch] feature/agent-ui -> origin/feature/agent-ui",
},
@@ -54,6 +68,167 @@ export const ExecuteSuccess: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(canvas.getByText(/From github\.com:coder\/coder/)).toBeVisible();
expect(canvas.queryByText("exit 0")).not.toBeInTheDocument();
expect(
canvas.queryByRole("img", { name: "Running in background" }),
).not.toBeInTheDocument();
expect(canvas.getByText("47.2s")).toBeVisible();
expect(canvas.queryByText("2 lines")).not.toBeInTheDocument();
},
};
export const ExecuteError: Story = {
args: {
name: "execute",
status: "error",
isError: true,
args: { command: longExecuteCommand },
shellToolDisplayMode: "always_collapsed",
result: {
wall_duration_ms: 8600,
exit_code: 1,
output: Array.from(
{ length: 47 },
(_, index) => `error line ${index + 1}`,
).join("\n"),
},
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(canvas.queryByText(/error line 1/)).not.toBeInTheDocument();
expect(canvas.getByRole("img", { name: "Command failed" })).toBeVisible();
expect(canvas.queryByText("exit 1")).not.toBeInTheDocument();
await userEvent.click(
canvas.getByRole("button", { name: "Expand command output" }),
);
await waitFor(() => {
expect(canvas.getByText(/error line 1/)).toBeVisible();
});
},
};
export const ExecuteBackgrounded: Story = {
args: {
name: "execute",
status: "completed",
args: { command: "npm start" },
shellToolDisplayMode: "always_collapsed",
result: {
background_process_id: "process-123",
output: "",
wall_duration_ms: 2100,
},
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const backgroundIndicator = canvas.getByRole("img", {
name: "Running in background",
});
expect(backgroundIndicator).toBeVisible();
await userEvent.hover(backgroundIndicator);
expect(await screen.findByRole("tooltip")).toHaveTextContent(
"Running in background",
);
},
};
export const ExecuteAlwaysCollapsed: Story = {
args: {
name: "execute",
status: "completed",
args: { command: executeCommand },
shellToolDisplayMode: "always_collapsed",
result: {
output: "From github.com:coder/coder\nFetching origin/main",
},
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(canvas.getByText(executeCommand)).toBeVisible();
expect(canvas.queryByText("exit 0")).not.toBeInTheDocument();
expect(canvas.queryByText("2 lines")).not.toBeInTheDocument();
expect(
canvas.queryByText(/From github\.com:coder\/coder/),
).not.toBeInTheDocument();
await userEvent.click(canvas.getByText(executeCommand));
await waitFor(() => {
expect(canvas.getByText(/From github\.com:coder\/coder/)).toBeVisible();
});
},
};
export const ExecuteLongCommandCollapsed: Story = {
args: {
name: "execute",
status: "completed",
args: { command: longExecuteCommand },
shellToolDisplayMode: "always_collapsed",
result: {
wall_duration_ms: 47200,
exit_code: 0,
output: Array.from(
{ length: 61 },
(_, index) => `output line ${index + 1}`,
).join("\n"),
},
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const command = canvas.getByText(longExecuteCommand);
expect(command).toBeVisible();
expect(
canvas.queryByRole("button", { name: longExecuteCommand }),
).not.toBeInTheDocument();
expect(canvas.queryByText("exit 0")).not.toBeInTheDocument();
expect(canvas.getByText("47.2s")).toBeVisible();
expect(canvas.queryByText("61 lines")).not.toBeInTheDocument();
},
};
export const ProcessOutputAlwaysCollapsed: Story = {
args: {
name: "process_output",
status: "completed",
shellToolDisplayMode: "always_collapsed",
result: {
output: "build completed\n0 errors",
},
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(canvas.queryByText(/build completed/)).not.toBeInTheDocument();
await userEvent.click(
canvas.getByRole("button", { name: "Expand process output" }),
);
await waitFor(() => {
expect(canvas.getByText(/build completed/)).toBeVisible();
});
},
};
export const ProcessOutputAlwaysExpanded: Story = {
args: {
name: "process_output",
status: "completed",
shellToolDisplayMode: "always_expanded",
result: {
output: Array.from(
{ length: 30 },
(_, index) => `process output line ${index + 1}`,
).join("\n"),
},
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(canvas.getByText(/process output line 1/)).toBeVisible();
expect(canvas.getByText(/process output line 30/)).toBeVisible();
await waitFor(() => {
expect(
canvas.getByRole("button", {
name: "Collapse full process output",
}),
).toHaveAttribute("aria-expanded", "true");
});
},
};
@@ -92,6 +92,7 @@ interface ToolProps extends Omit<ComponentPropsWithRef<"div">, "children"> {
previousResponseText?: string;
/** Human-readable intent extracted from the model's tool-call args. */
modelIntent?: string;
shellToolDisplayMode?: TypesGen.AgentDisplayMode;
codeDiffDisplayMode?: TypesGen.AgentDisplayMode;
}
@@ -116,6 +117,7 @@ type ToolRendererProps = {
mcpServerConfigId?: string;
mcpServers?: readonly TypesGen.MCPServerConfig[];
modelIntent?: string;
shellToolDisplayMode?: TypesGen.AgentDisplayMode;
codeDiffDisplayMode?: TypesGen.AgentDisplayMode;
};
@@ -218,11 +220,19 @@ const ExecuteRenderer: FC<ToolRendererProps> = ({
result,
isError,
killedBySignal,
shellToolDisplayMode,
}) => {
const parsedArgs = parseArgs(args);
const command = parsedArgs ? asString(parsedArgs.command) : "";
const rec = asRecord(result);
const output = rec ? asString(rec.output).trim() : "";
const durationMs = rec
? (asNumber(rec.wall_duration_ms, { parseString: true }) ??
asNumber(rec.duration_ms, { parseString: true }))
: undefined;
const isBackgrounded = Boolean(
rec && asString(rec.background_process_id).trim(),
);
const authRequired = rec ? Boolean(rec.auth_required) : false;
const authenticateURL = rec ? asString(rec.authenticate_url).trim() : "";
const providerLabel = toProviderLabel(
@@ -247,7 +257,10 @@ const ExecuteRenderer: FC<ToolRendererProps> = ({
output={output}
status={status}
isError={isError}
durationMs={durationMs}
isBackgrounded={isBackgrounded}
killedBySignal={killedBySignal}
shellToolDisplayMode={shellToolDisplayMode}
/>
);
};
@@ -257,13 +270,12 @@ const ProcessOutputRenderer: FC<ToolRendererProps> = ({
result,
isError,
killedBySignal,
shellToolDisplayMode,
}) => {
const rec = asRecord(result);
const output = rec ? asString(rec.output).trim() : "";
const exitCode = rec
? rec.exit_code !== undefined && rec.exit_code !== null
? Number(rec.exit_code)
: null
? (asNumber(rec.exit_code, { parseString: true }) ?? null)
: null;
return (
@@ -273,6 +285,7 @@ const ProcessOutputRenderer: FC<ToolRendererProps> = ({
exitCode={exitCode}
isError={isError}
killedBySignal={killedBySignal}
shellToolDisplayMode={shellToolDisplayMode}
/>
);
};
@@ -1037,6 +1050,7 @@ export const Tool = memo(
isLatestAskUserQuestion,
previousResponseText,
modelIntent,
shellToolDisplayMode,
codeDiffDisplayMode,
ref,
...props
@@ -1044,21 +1058,18 @@ export const Tool = memo(
const Renderer = isSubagentToolName(name)
? SubagentRenderer
: (toolRenderers[name] ?? GenericToolRenderer);
const isShellTool = name === "execute" || name === "process_output";
return (
<div
ref={ref}
data-tool-call=""
data-shell-tool={isShellTool ? "" : undefined}
className={cn(
name === "execute" ||
name === "process_output" ||
name === "propose_plan" ||
name === "advisor"
isShellTool || name === "propose_plan" || name === "advisor"
? "w-full py-0.5"
: "py-0.5",
// Collapse padding between adjacent tool calls so they hug.
// Bottom padding is removed on a tool followed by a tool, and
// top padding is removed on a tool preceded by a tool.
// Keep back-to-back tool cards visually grouped so stacked tool calls do not look double-spaced.
"[&:has(+[data-tool-call])]:pb-0",
"[[data-tool-call]+&]:pt-0",
className,
@@ -1084,6 +1095,7 @@ export const Tool = memo(
isLatestAskUserQuestion={isLatestAskUserQuestion}
previousResponseText={previousResponseText}
modelIntent={modelIntent}
shellToolDisplayMode={shellToolDisplayMode}
codeDiffDisplayMode={codeDiffDisplayMode}
/>
</div>
@@ -9,13 +9,16 @@ import {
resolveAgentDisplayState,
} from "./displayMode";
type ToolCollapsibleAriaLabel = string | ((expanded: boolean) => string);
type ToolCollapsibleHeader = ReactNode | ((expanded: boolean) => ReactNode);
interface ToolCollapsibleProps {
children: ReactNode;
header: ToolCollapsibleHeader;
headerActions?: ReactNode;
hasContent?: boolean;
defaultExpanded?: boolean;
ariaLabel?: ToolCollapsibleAriaLabel;
className?: string;
headerClassName?: string;
}
@@ -30,6 +33,7 @@ export const AgentDisplayModeToolCollapsible: FC<
AgentDisplayModeToolCollapsibleProps
> = ({ displayMode, autoDisplayState, ...props }) => {
const displayState = resolveAgentDisplayState(displayMode, autoDisplayState);
return (
<ToolCollapsible
key={`${displayMode ?? "auto"}:${autoDisplayState}`}
@@ -42,45 +46,63 @@ export const AgentDisplayModeToolCollapsible: FC<
export const ToolCollapsible: FC<ToolCollapsibleProps> = ({
children,
header,
headerActions,
hasContent = true,
defaultExpanded = false,
ariaLabel,
className,
headerClassName,
}) => {
const [expanded, setExpanded] = useState(defaultExpanded);
const renderedHeader =
typeof header === "function" ? header(expanded) : header;
const headerButton = hasContent ? (
<button
type="button"
aria-expanded={expanded}
aria-label={
typeof ariaLabel === "function" ? ariaLabel(expanded) : ariaLabel
}
onClick={() => setExpanded(!expanded)}
className={cn(
"border-0 bg-transparent p-0 m-0 font-[inherit] text-[inherit] text-left",
"flex items-center gap-2 cursor-pointer",
"text-content-secondary transition-colors hover:text-content-primary",
headerActions ? "min-w-0 flex-1" : "w-full",
headerClassName,
)}
>
{renderedHeader}
<ChevronDownIcon
className={cn(
"h-3 w-3 shrink-0 text-current transition-transform",
expanded ? "rotate-0" : "-rotate-90",
)}
/>
</button>
) : (
<div
className={cn(
"flex items-center gap-2 text-content-secondary",
headerActions && "min-w-0 flex-1",
headerClassName,
)}
>
{renderedHeader}
</div>
);
return (
<div className={className}>
{hasContent ? (
<button
type="button"
aria-expanded={expanded}
onClick={() => setExpanded(!expanded)}
className={cn(
"border-0 bg-transparent p-0 m-0 font-[inherit] text-[inherit] text-left",
"flex w-full items-center gap-2 cursor-pointer",
"text-content-secondary transition-colors hover:text-content-primary",
headerClassName,
)}
>
{renderedHeader}
<ChevronDownIcon
className={cn(
"h-3 w-3 shrink-0 text-current transition-transform",
expanded ? "rotate-0" : "-rotate-90",
)}
/>
</button>
) : (
<div
className={cn(
"flex items-center gap-2 text-content-secondary",
headerClassName,
)}
>
{renderedHeader}
{headerActions ? (
<div className="flex w-full items-center gap-2">
{headerButton}
<div className="flex shrink-0 items-center gap-1">
{headerActions}
</div>
</div>
) : (
headerButton
)}
{expanded && hasContent && children}
</div>
@@ -1,6 +1,5 @@
import { describe, expect, it, vi } from "vitest";
import {
BORDER_BG_STYLE,
buildEditDiff,
buildWriteFileDiff,
COLLAPSED_OUTPUT_HEIGHT,
@@ -9,6 +8,7 @@ import {
diffViewerCSS,
fileViewerCSS,
formatResultOutput,
formatShellDurationMs,
getDiffViewerOptions,
getFileContentForViewer,
getFileViewerOptions,
@@ -70,20 +70,45 @@ describe("shortDurationMs", () => {
expect(shortDurationMs(1000)).toBe("1s");
expect(shortDurationMs(30_000)).toBe("30s");
expect(shortDurationMs(59_000)).toBe("59s");
expect(shortDurationMs(59_499)).toBe("59s");
});
it("formats minutes", () => {
expect(shortDurationMs(59_500)).toBe("1m");
expect(shortDurationMs(60_000)).toBe("1m");
expect(shortDurationMs(300_000)).toBe("5m");
expect(shortDurationMs(3_540_000)).toBe("59m");
expect(shortDurationMs(3_569_999)).toBe("59m");
});
it("formats hours", () => {
expect(shortDurationMs(3_570_000)).toBe("1h");
expect(shortDurationMs(3_600_000)).toBe("1h");
expect(shortDurationMs(7_200_000)).toBe("2h");
});
});
describe("formatShellDurationMs", () => {
it("returns empty string for invalid values", () => {
expect(formatShellDurationMs(undefined)).toBe("");
expect(formatShellDurationMs(-1)).toBe("");
expect(formatShellDurationMs(Number.NaN)).toBe("");
expect(formatShellDurationMs(Number.POSITIVE_INFINITY)).toBe("");
});
it("formats milliseconds and rounded seconds", () => {
expect(formatShellDurationMs(100)).toBe("100ms");
expect(formatShellDurationMs(47_200)).toBe("47.2s");
expect(formatShellDurationMs(59_949)).toBe("59.9s");
expect(formatShellDurationMs(59_950)).toBe("1m");
});
it("formats rounded minutes and hours", () => {
expect(formatShellDurationMs(3_596_999)).toBe("59.9m");
expect(formatShellDurationMs(3_597_000)).toBe("1h");
});
});
describe("normalizeStatus", () => {
it("lowercases and trims", () => {
expect(normalizeStatus(" COMPLETED ")).toBe("completed");
@@ -840,13 +865,6 @@ describe("constants", () => {
expect(DIFFS_FONT_STYLE).toHaveProperty("--diffs-line-height", "1.5");
});
it("BORDER_BG_STYLE has expected background", () => {
expect(BORDER_BG_STYLE).toHaveProperty(
"background",
"hsl(var(--border-default))",
);
});
it("fileViewerCSS is a non-empty string", () => {
expect(typeof fileViewerCSS).toBe("string");
expect(fileViewerCSS.length).toBeGreaterThan(0);
@@ -55,11 +55,38 @@ export const shortDurationMs = (durationMs: number | undefined): string => {
if (seconds < 60) {
return `${seconds}s`;
}
const minutes = Math.round(seconds / 60);
const minutes = Math.round(durationMs / 60_000);
if (minutes < 60) {
return `${minutes}m`;
}
const hours = Math.round(minutes / 60);
const hours = Math.round(durationMs / 3_600_000);
return `${hours}h`;
};
const roundToTenths = (value: number): number => Number(value.toFixed(1));
export const formatShellDurationMs = (
durationMs: number | undefined,
): string => {
if (
durationMs === undefined ||
durationMs < 0 ||
!Number.isFinite(durationMs)
) {
return "";
}
if (durationMs < 1000) {
return `${Math.round(durationMs)}ms`;
}
const seconds = roundToTenths(durationMs / 1000);
if (seconds < 60) {
return `${seconds}s`;
}
const minutes = roundToTenths(durationMs / 60_000);
if (minutes < 60) {
return `${minutes}m`;
}
const hours = roundToTenths(durationMs / 3_600_000);
return `${hours}h`;
};
@@ -439,10 +466,6 @@ export const DIFFS_FONT_STYLE = {
"--diffs-line-height": "1.5",
} as CSSProperties;
export const BORDER_BG_STYLE = {
background: "hsl(var(--border-default))",
};
/**
* Checks whether a tool result should be rendered as a syntax-highlighted
* file viewer. Returns the file path, content, and whether the header
@@ -115,6 +115,23 @@ export const ThinkingDisplaySettings: FC = () => {
);
};
export const ShellToolDisplaySettings: FC = () => {
return (
<DisplayModeSettings
title="Shell Output Display"
description="How shell command output should be displayed by default. 'Auto' opens running commands and completed commands with output, then keeps empty output collapsed. 'Always Expanded' opens shell output by default. 'Always Collapsed' keeps it collapsed."
ariaLabel="Shell output display mode"
errorMessage="Failed to save your shell output display preference."
defaultValue="auto"
options={agentDisplayOptions}
getMode={(settings) => settings.shell_tool_display_mode}
updateSettings={(value) => ({
shell_tool_display_mode: value,
})}
/>
);
};
export const CodeDiffDisplaySettings: FC = () => {
return (
<DisplayModeSettings
@@ -215,6 +215,7 @@ export const EnablingTaskNotificationClearsAlertDismissal: Story = {
data: {
task_notification_alert_dismissed: true,
thinking_display_mode: "auto" as const,
shell_tool_display_mode: "auto" as const,
code_diff_display_mode: "auto" as const,
agent_chat_send_shortcut: "enter" as const,
},
@@ -240,6 +241,7 @@ export const EnablingTaskNotificationClearsAlertDismissal: Story = {
).mockResolvedValue({
task_notification_alert_dismissed: false,
thinking_display_mode: "auto",
shell_tool_display_mode: "auto",
code_diff_display_mode: "auto",
agent_chat_send_shortcut: "enter" as const,
});