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": { "code_diff_display_mode": {
"$ref": "#/definitions/codersdk.AgentDisplayMode" "$ref": "#/definitions/codersdk.AgentDisplayMode"
}, },
"shell_tool_display_mode": {
"$ref": "#/definitions/codersdk.AgentDisplayMode"
},
"task_notification_alert_dismissed": { "task_notification_alert_dismissed": {
"type": "boolean" "type": "boolean"
}, },
@@ -24060,6 +24063,9 @@ const docTemplate = `{
"code_diff_display_mode": { "code_diff_display_mode": {
"$ref": "#/definitions/codersdk.AgentDisplayMode" "$ref": "#/definitions/codersdk.AgentDisplayMode"
}, },
"shell_tool_display_mode": {
"$ref": "#/definitions/codersdk.AgentDisplayMode"
},
"task_notification_alert_dismissed": { "task_notification_alert_dismissed": {
"type": "boolean" "type": "boolean"
}, },
+6
View File
@@ -21694,6 +21694,9 @@
"code_diff_display_mode": { "code_diff_display_mode": {
"$ref": "#/definitions/codersdk.AgentDisplayMode" "$ref": "#/definitions/codersdk.AgentDisplayMode"
}, },
"shell_tool_display_mode": {
"$ref": "#/definitions/codersdk.AgentDisplayMode"
},
"task_notification_alert_dismissed": { "task_notification_alert_dismissed": {
"type": "boolean" "type": "boolean"
}, },
@@ -22153,6 +22156,9 @@
"code_diff_display_mode": { "code_diff_display_mode": {
"$ref": "#/definitions/codersdk.AgentDisplayMode" "$ref": "#/definitions/codersdk.AgentDisplayMode"
}, },
"shell_tool_display_mode": {
"$ref": "#/definitions/codersdk.AgentDisplayMode"
},
"task_notification_alert_dismissed": { "task_notification_alert_dismissed": {
"type": "boolean" "type": "boolean"
}, },
+22
View File
@@ -4546,6 +4546,17 @@ func (q *querier) GetUserSecretsTelemetrySummary(ctx context.Context) (database.
return q.db.GetUserSecretsTelemetrySummary(ctx) 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) { func (q *querier) GetUserStatusCounts(ctx context.Context, arg database.GetUserStatusCountsParams) ([]database.GetUserStatusCountsRow, error) {
if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceUser); err != nil { if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceUser); err != nil {
return nil, err return nil, err
@@ -7213,6 +7224,17 @@ func (q *querier) UpdateUserSecretByUserIDAndName(ctx context.Context, arg datab
return q.db.UpdateUserSecretByUserIDAndName(ctx, arg) 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) { func (q *querier) UpdateUserStatus(ctx context.Context, arg database.UpdateUserStatusParams) (database.User, error) {
fetch := func(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) 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() dbm.EXPECT().UpdateUserThinkingDisplayMode(gomock.Any(), arg).Return("always_expanded", nil).AnyTimes()
check.Args(arg).Asserts(u, policy.ActionUpdatePersonal).Returns("always_expanded") 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) { s.Run("GetUserCodeDiffDisplayMode", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
u := testutil.Fake(s.T(), faker, database.User{}) u := testutil.Fake(s.T(), faker, database.User{})
dbm.EXPECT().GetUserByID(gomock.Any(), u.ID).Return(u, nil).AnyTimes() 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 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) { func (m queryMetricsStore) GetUserStatusCounts(ctx context.Context, arg database.GetUserStatusCountsParams) ([]database.GetUserStatusCountsRow, error) {
start := time.Now() start := time.Now()
r0, r1 := m.s.GetUserStatusCounts(ctx, arg) r0, r1 := m.s.GetUserStatusCounts(ctx, arg)
@@ -5153,6 +5161,14 @@ func (m queryMetricsStore) UpdateUserSecretByUserIDAndName(ctx context.Context,
return r0, r1 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) { func (m queryMetricsStore) UpdateUserStatus(ctx context.Context, arg database.UpdateUserStatusParams) (database.User, error) {
start := time.Now() start := time.Now()
r0, r1 := m.s.UpdateUserStatus(ctx, arg) 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) 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. // GetUserStatusCounts mocks base method.
func (m *MockStore) GetUserStatusCounts(ctx context.Context, arg database.GetUserStatusCountsParams) ([]database.GetUserStatusCountsRow, error) { func (m *MockStore) GetUserStatusCounts(ctx context.Context, arg database.GetUserStatusCountsParams) ([]database.GetUserStatusCountsRow, error) {
m.ctrl.T.Helper() 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) 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. // UpdateUserStatus mocks base method.
func (m *MockStore) UpdateUserStatus(ctx context.Context, arg database.UpdateUserStatusParams) (database.User, error) { func (m *MockStore) UpdateUserStatus(ctx context.Context, arg database.UpdateUserStatusParams) (database.User, error) {
m.ctrl.T.Helper() m.ctrl.T.Helper()
+2
View File
@@ -769,6 +769,7 @@ type sqlcQuerier interface {
// percentile_disc returns an actual integer count from the underlying // percentile_disc returns an actual integer count from the underlying
// values rather than interpolating between rows. // values rather than interpolating between rows.
GetUserSecretsTelemetrySummary(ctx context.Context) (GetUserSecretsTelemetrySummaryRow, error) 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. // 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. // The time range is inclusively defined by the start_time and end_time parameters.
GetUserStatusCounts(ctx context.Context, arg GetUserStatusCountsParams) ([]GetUserStatusCountsRow, error) GetUserStatusCounts(ctx context.Context, arg GetUserStatusCountsParams) ([]GetUserStatusCountsRow, error)
@@ -1223,6 +1224,7 @@ type sqlcQuerier interface {
UpdateUserQuietHoursSchedule(ctx context.Context, arg UpdateUserQuietHoursScheduleParams) (User, error) UpdateUserQuietHoursSchedule(ctx context.Context, arg UpdateUserQuietHoursScheduleParams) (User, error)
UpdateUserRoles(ctx context.Context, arg UpdateUserRolesParams) (User, error) UpdateUserRoles(ctx context.Context, arg UpdateUserRolesParams) (User, error)
UpdateUserSecretByUserIDAndName(ctx context.Context, arg UpdateUserSecretByUserIDAndNameParams) (UserSecret, 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) UpdateUserStatus(ctx context.Context, arg UpdateUserStatusParams) (User, error)
UpdateUserTaskNotificationAlertDismissed(ctx context.Context, arg UpdateUserTaskNotificationAlertDismissedParams) (bool, error) UpdateUserTaskNotificationAlertDismissed(ctx context.Context, arg UpdateUserTaskNotificationAlertDismissedParams) (bool, error)
UpdateUserTerminalFont(ctx context.Context, arg UpdateUserTerminalFontParams) (UserConfig, 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 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 const getUserTaskNotificationAlertDismissed = `-- name: GetUserTaskNotificationAlertDismissed :one
SELECT SELECT
value::boolean as task_notification_alert_dismissed value::boolean as task_notification_alert_dismissed
@@ -26869,6 +26886,33 @@ func (q *sqlQuerier) UpdateUserRoles(ctx context.Context, arg UpdateUserRolesPar
return i, err 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 const updateUserStatus = `-- name: UpdateUserStatus :one
UPDATE UPDATE
users users
+23
View File
@@ -347,6 +347,29 @@ WHERE user_configs.user_id = @user_id
AND user_configs.key = 'preference_thinking_display_mode' AND user_configs.key = 'preference_thinking_display_mode'
RETURNING value AS 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 -- name: GetUserCodeDiffDisplayMode :one
SELECT SELECT
value AS code_diff_display_mode value AS code_diff_display_mode
+37
View File
@@ -1306,6 +1306,15 @@ func (api *API) userPreferenceSettings(rw http.ResponseWriter, r *http.Request)
return 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) codeDiffMode, err := api.Database.GetUserCodeDiffDisplayMode(ctx, user.ID)
if err != nil && !errors.Is(err, sql.ErrNoRows) { if err != nil && !errors.Is(err, sql.ErrNoRows) {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ 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{ httpapi.Write(ctx, rw, http.StatusOK, codersdk.UserPreferenceSettings{
TaskNotificationAlertDismissed: taskAlertDismissed, TaskNotificationAlertDismissed: taskAlertDismissed,
ThinkingDisplayMode: sanitizeThinkingDisplayMode(thinkingMode), ThinkingDisplayMode: sanitizeThinkingDisplayMode(thinkingMode),
ShellToolDisplayMode: sanitizeAgentDisplayMode(shellToolMode),
CodeDiffDisplayMode: sanitizeAgentDisplayMode(codeDiffMode), CodeDiffDisplayMode: sanitizeAgentDisplayMode(codeDiffMode),
AgentChatSendShortcut: sanitizeAgentChatSendShortcut(agentChatSendShortcut), AgentChatSendShortcut: sanitizeAgentChatSendShortcut(agentChatSendShortcut),
}) })
@@ -1363,6 +1373,16 @@ func (api *API) putUserPreferenceSettings(rw http.ResponseWriter, r *http.Reques
}) })
return 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 != "" && if params.CodeDiffDisplayMode != "" &&
!slices.Contains(codersdk.ValidAgentDisplayModes, params.CodeDiffDisplayMode) { !slices.Contains(codersdk.ValidAgentDisplayModes, params.CodeDiffDisplayMode) {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ 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) 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 != "" { if params.CodeDiffDisplayMode != "" {
updated, err := tx.UpdateUserCodeDiffDisplayMode(ctx, database.UpdateUserCodeDiffDisplayModeParams{ updated, err := tx.UpdateUserCodeDiffDisplayMode(ctx, database.UpdateUserCodeDiffDisplayModeParams{
UserID: user.ID, UserID: user.ID,
+63 -5
View File
@@ -2433,9 +2433,34 @@ func TestAgentDisplayModePreferences(t *testing.T) {
settings, err := client.GetUserPreferenceSettings(ctx, codersdk.Me) settings, err := client.GetUserPreferenceSettings(ctx, codersdk.Me)
require.NoError(t, err) require.NoError(t, err)
require.Equal(t, codersdk.AgentDisplayModeAuto, settings.ShellToolDisplayMode)
require.Equal(t, codersdk.AgentDisplayModeAuto, settings.CodeDiffDisplayMode) 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.Run("round-trips code diff display mode", func(t *testing.T) {
t.Parallel() t.Parallel()
@@ -2469,24 +2494,57 @@ func TestAgentDisplayModePreferences(t *testing.T) {
defer cancel() defer cancel()
_, err := client.UpdateUserPreferenceSettings(ctx, codersdk.Me, codersdk.UpdateUserPreferenceSettingsRequest{ _, err := client.UpdateUserPreferenceSettings(ctx, codersdk.Me, codersdk.UpdateUserPreferenceSettingsRequest{
ThinkingDisplayMode: codersdk.ThinkingDisplayModePreview, ThinkingDisplayMode: codersdk.ThinkingDisplayModePreview,
CodeDiffDisplayMode: codersdk.AgentDisplayModeAlwaysExpanded, ShellToolDisplayMode: codersdk.AgentDisplayModeAlwaysCollapsed,
CodeDiffDisplayMode: codersdk.AgentDisplayModeAlwaysExpanded,
}) })
require.NoError(t, err) require.NoError(t, err)
updated, err := client.UpdateUserPreferenceSettings(ctx, codersdk.Me, codersdk.UpdateUserPreferenceSettingsRequest{ updated, err := client.UpdateUserPreferenceSettings(ctx, codersdk.Me, codersdk.UpdateUserPreferenceSettingsRequest{
ThinkingDisplayMode: codersdk.ThinkingDisplayModeAlwaysExpanded, ShellToolDisplayMode: codersdk.AgentDisplayModeAlwaysExpanded,
}) })
require.NoError(t, err) 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) require.Equal(t, codersdk.AgentDisplayModeAlwaysExpanded, updated.CodeDiffDisplayMode)
settings, err := client.GetUserPreferenceSettings(ctx, codersdk.Me) settings, err := client.GetUserPreferenceSettings(ctx, codersdk.Me)
require.NoError(t, err) 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) 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.Run("rejects invalid code diff display mode", func(t *testing.T) {
t.Parallel() t.Parallel()
+2
View File
@@ -305,6 +305,7 @@ type UpdateUserAppearanceSettingsRequest struct {
type UserPreferenceSettings struct { type UserPreferenceSettings struct {
TaskNotificationAlertDismissed bool `json:"task_notification_alert_dismissed"` TaskNotificationAlertDismissed bool `json:"task_notification_alert_dismissed"`
ThinkingDisplayMode ThinkingDisplayMode `json:"thinking_display_mode"` ThinkingDisplayMode ThinkingDisplayMode `json:"thinking_display_mode"`
ShellToolDisplayMode AgentDisplayMode `json:"shell_tool_display_mode"`
CodeDiffDisplayMode AgentDisplayMode `json:"code_diff_display_mode"` CodeDiffDisplayMode AgentDisplayMode `json:"code_diff_display_mode"`
AgentChatSendShortcut AgentChatSendShortcut `json:"agent_chat_send_shortcut"` AgentChatSendShortcut AgentChatSendShortcut `json:"agent_chat_send_shortcut"`
} }
@@ -312,6 +313,7 @@ type UserPreferenceSettings struct {
type UpdateUserPreferenceSettingsRequest struct { type UpdateUserPreferenceSettingsRequest struct {
TaskNotificationAlertDismissed *bool `json:"task_notification_alert_dismissed,omitempty"` TaskNotificationAlertDismissed *bool `json:"task_notification_alert_dismissed,omitempty"`
ThinkingDisplayMode ThinkingDisplayMode `json:"thinking_display_mode,omitempty"` ThinkingDisplayMode ThinkingDisplayMode `json:"thinking_display_mode,omitempty"`
ShellToolDisplayMode AgentDisplayMode `json:"shell_tool_display_mode,omitempty"`
CodeDiffDisplayMode AgentDisplayMode `json:"code_diff_display_mode,omitempty"` CodeDiffDisplayMode AgentDisplayMode `json:"code_diff_display_mode,omitempty"`
AgentChatSendShortcut AgentChatSendShortcut `json:"agent_chat_send_shortcut,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", "agent_chat_send_shortcut": "enter",
"code_diff_display_mode": "auto", "code_diff_display_mode": "auto",
"shell_tool_display_mode": "auto",
"task_notification_alert_dismissed": true, "task_notification_alert_dismissed": true,
"thinking_display_mode": "auto" "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 | | | | `agent_chat_send_shortcut` | [codersdk.AgentChatSendShortcut](#codersdkagentchatsendshortcut) | false | | |
| `code_diff_display_mode` | [codersdk.AgentDisplayMode](#codersdkagentdisplaymode) | false | | | | `code_diff_display_mode` | [codersdk.AgentDisplayMode](#codersdkagentdisplaymode) | false | | |
| `shell_tool_display_mode` | [codersdk.AgentDisplayMode](#codersdkagentdisplaymode) | false | | |
| `task_notification_alert_dismissed` | boolean | false | | | | `task_notification_alert_dismissed` | boolean | false | | |
| `thinking_display_mode` | [codersdk.ThinkingDisplayMode](#codersdkthinkingdisplaymode) | 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", "agent_chat_send_shortcut": "enter",
"code_diff_display_mode": "auto", "code_diff_display_mode": "auto",
"shell_tool_display_mode": "auto",
"task_notification_alert_dismissed": true, "task_notification_alert_dismissed": true,
"thinking_display_mode": "auto" "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 | | | | `agent_chat_send_shortcut` | [codersdk.AgentChatSendShortcut](#codersdkagentchatsendshortcut) | false | | |
| `code_diff_display_mode` | [codersdk.AgentDisplayMode](#codersdkagentdisplaymode) | false | | | | `code_diff_display_mode` | [codersdk.AgentDisplayMode](#codersdkagentdisplaymode) | false | | |
| `shell_tool_display_mode` | [codersdk.AgentDisplayMode](#codersdkagentdisplaymode) | false | | |
| `task_notification_alert_dismissed` | boolean | false | | | | `task_notification_alert_dismissed` | boolean | false | | |
| `thinking_display_mode` | [codersdk.ThinkingDisplayMode](#codersdkthinkingdisplaymode) | 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", "agent_chat_send_shortcut": "enter",
"code_diff_display_mode": "auto", "code_diff_display_mode": "auto",
"shell_tool_display_mode": "auto",
"task_notification_alert_dismissed": true, "task_notification_alert_dismissed": true,
"thinking_display_mode": "auto" "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", "agent_chat_send_shortcut": "enter",
"code_diff_display_mode": "auto", "code_diff_display_mode": "auto",
"shell_tool_display_mode": "auto",
"task_notification_alert_dismissed": true, "task_notification_alert_dismissed": true,
"thinking_display_mode": "auto" "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", "agent_chat_send_shortcut": "enter",
"code_diff_display_mode": "auto", "code_diff_display_mode": "auto",
"shell_tool_display_mode": "auto",
"task_notification_alert_dismissed": true, "task_notification_alert_dismissed": true,
"thinking_display_mode": "auto" "thinking_display_mode": "auto"
} }
+2
View File
@@ -8507,6 +8507,7 @@ export interface UpdateUserPasswordRequest {
export interface UpdateUserPreferenceSettingsRequest { export interface UpdateUserPreferenceSettingsRequest {
readonly task_notification_alert_dismissed?: boolean; readonly task_notification_alert_dismissed?: boolean;
readonly thinking_display_mode?: ThinkingDisplayMode; readonly thinking_display_mode?: ThinkingDisplayMode;
readonly shell_tool_display_mode?: AgentDisplayMode;
readonly code_diff_display_mode?: AgentDisplayMode; readonly code_diff_display_mode?: AgentDisplayMode;
readonly agent_chat_send_shortcut?: AgentChatSendShortcut; readonly agent_chat_send_shortcut?: AgentChatSendShortcut;
} }
@@ -8903,6 +8904,7 @@ export interface UserParameter {
export interface UserPreferenceSettings { export interface UserPreferenceSettings {
readonly task_notification_alert_dismissed: boolean; readonly task_notification_alert_dismissed: boolean;
readonly thinking_display_mode: ThinkingDisplayMode; readonly thinking_display_mode: ThinkingDisplayMode;
readonly shell_tool_display_mode: AgentDisplayMode;
readonly code_diff_display_mode: AgentDisplayMode; readonly code_diff_display_mode: AgentDisplayMode;
readonly agent_chat_send_shortcut: AgentChatSendShortcut; readonly agent_chat_send_shortcut: AgentChatSendShortcut;
} }
@@ -10,6 +10,7 @@ import {
const preferencesData = { const preferencesData = {
task_notification_alert_dismissed: false, task_notification_alert_dismissed: false,
thinking_display_mode: "auto" as const, thinking_display_mode: "auto" as const,
shell_tool_display_mode: "auto" as const,
code_diff_display_mode: "auto" as const, code_diff_display_mode: "auto" as const,
agent_chat_send_shortcut: "enter" as const, agent_chat_send_shortcut: "enter" as const,
}; };
@@ -177,6 +178,7 @@ export const RendersAgentDisplayModeSettings: Story = {
const canvas = within(canvasElement); const canvas = within(canvasElement);
expect(await canvas.findByText("Thinking Display")).toBeVisible(); expect(await canvas.findByText("Thinking Display")).toBeVisible();
expect(await canvas.findByText("Shell Output Display")).toBeVisible();
expect(await canvas.findByText("Code Diff 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 { ChatSendShortcutSettings } from "./components/ChatSendShortcutSettings";
import { import {
CodeDiffDisplaySettings, CodeDiffDisplaySettings,
ShellToolDisplaySettings,
ThinkingDisplaySettings, ThinkingDisplaySettings,
} from "./components/DisplayModeSettings"; } from "./components/DisplayModeSettings";
import { PersonalInstructionsSettings } from "./components/PersonalInstructionsSettings"; import { PersonalInstructionsSettings } from "./components/PersonalInstructionsSettings";
@@ -60,6 +61,7 @@ export const AgentSettingsGeneralPageView: FC<
<ChatFullWidthSettings /> <ChatFullWidthSettings />
<ChatSendShortcutSettings /> <ChatSendShortcutSettings />
<ThinkingDisplaySettings /> <ThinkingDisplaySettings />
<ShellToolDisplaySettings />
<CodeDiffDisplaySettings /> <CodeDiffDisplaySettings />
<UserChatDebugLoggingSettings <UserChatDebugLoggingSettings
userSettings={userDebugLoggingData} userSettings={userDebugLoggingData}
@@ -1841,7 +1841,7 @@ export const AssistantActionBarAfterHiddenMessages: Story = {
}, },
}; };
export const CodeDiffDisplayModeFromPreferences: Story = { export const ToolDisplayModesFromPreferences: Story = {
parameters: { parameters: {
queries: [ queries: [
{ {
@@ -1849,6 +1849,7 @@ export const CodeDiffDisplayModeFromPreferences: Story = {
data: { data: {
task_notification_alert_dismissed: false, task_notification_alert_dismissed: false,
thinking_display_mode: "auto" as const, thinking_display_mode: "auto" as const,
shell_tool_display_mode: "always_collapsed" as const,
code_diff_display_mode: "always_collapsed" as const, code_diff_display_mode: "always_collapsed" as const,
agent_chat_send_shortcut: "enter" as const, agent_chat_send_shortcut: "enter" as const,
}, },
@@ -1871,6 +1872,14 @@ export const CodeDiffDisplayModeFromPreferences: Story = {
toolCalls: [], toolCalls: [],
toolResults: [], toolResults: [],
tools: [ tools: [
{
id: "execute-tool",
name: "execute",
args: { command: "pnpm test" },
result: { output: "tests passed" },
isError: false,
status: "completed",
},
{ {
id: "edit-tool", id: "edit-tool",
name: "edit_files", name: "edit_files",
@@ -1892,7 +1901,10 @@ export const CodeDiffDisplayModeFromPreferences: Story = {
status: "completed", status: "completed",
}, },
], ],
blocks: [{ type: "tool", id: "edit-tool" }], blocks: [
{ type: "tool", id: "execute-tool" },
{ type: "tool", id: "edit-tool" },
],
sources: [], sources: [],
}, },
}, },
@@ -1900,9 +1912,20 @@ export const CodeDiffDisplayModeFromPreferences: Story = {
}, },
play: async ({ canvasElement }) => { play: async ({ canvasElement }) => {
const canvas = within(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.getByText(/Edited config\.ts/)).toBeVisible();
expect(canvas.queryAllByTestId("edit-file-diff")).toHaveLength(0); 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", { const editFilesButton = canvas.getByRole("button", {
name: /Edited config\.ts/, name: /Edited config\.ts/,
}); });
@@ -1926,6 +1949,7 @@ export const ThinkingBlockAlwaysExpanded: Story = {
data: { data: {
task_notification_alert_dismissed: false, task_notification_alert_dismissed: false,
thinking_display_mode: "always_expanded" as const, thinking_display_mode: "always_expanded" as const,
shell_tool_display_mode: "auto" as const,
code_diff_display_mode: "auto" as const, code_diff_display_mode: "auto" as const,
agent_chat_send_shortcut: "enter" as const, agent_chat_send_shortcut: "enter" as const,
}, },
@@ -1975,6 +1999,7 @@ export const ThinkingBlockAlwaysCollapsed: Story = {
data: { data: {
task_notification_alert_dismissed: false, task_notification_alert_dismissed: false,
thinking_display_mode: "always_collapsed" as const, thinking_display_mode: "always_collapsed" as const,
shell_tool_display_mode: "auto" as const,
code_diff_display_mode: "auto" as const, code_diff_display_mode: "auto" as const,
agent_chat_send_shortcut: "enter" as const, agent_chat_send_shortcut: "enter" as const,
}, },
@@ -2025,6 +2050,7 @@ export const ThinkingBlockWithToolCall: Story = {
data: { data: {
task_notification_alert_dismissed: false, task_notification_alert_dismissed: false,
thinking_display_mode: "always_collapsed" as const, thinking_display_mode: "always_collapsed" as const,
shell_tool_display_mode: "auto" as const,
code_diff_display_mode: "auto" as const, code_diff_display_mode: "auto" as const,
agent_chat_send_shortcut: "enter" as const, agent_chat_send_shortcut: "enter" as const,
}, },
@@ -2088,6 +2114,7 @@ export const ThinkingBlockAutoMode: Story = {
data: { data: {
task_notification_alert_dismissed: false, task_notification_alert_dismissed: false,
thinking_display_mode: "auto" as const, thinking_display_mode: "auto" as const,
shell_tool_display_mode: "auto" as const,
code_diff_display_mode: "auto" as const, code_diff_display_mode: "auto" as const,
agent_chat_send_shortcut: "enter" as const, agent_chat_send_shortcut: "enter" as const,
}, },
@@ -2141,6 +2168,7 @@ export const ThinkingBlockPreviewMode: Story = {
data: { data: {
task_notification_alert_dismissed: false, task_notification_alert_dismissed: false,
thinking_display_mode: "preview" as const, thinking_display_mode: "preview" as const,
shell_tool_display_mode: "auto" as const,
code_diff_display_mode: "auto" as const, code_diff_display_mode: "auto" as const,
agent_chat_send_shortcut: "enter" as const, agent_chat_send_shortcut: "enter" as const,
}, },
@@ -275,6 +275,8 @@ export const BlockList: FC<{
const prefQuery = useQuery(preferenceSettings()); const prefQuery = useQuery(preferenceSettings());
const thinkingDisplayMode: ThinkingDisplayMode = const thinkingDisplayMode: ThinkingDisplayMode =
prefQuery.data?.thinking_display_mode || "auto"; prefQuery.data?.thinking_display_mode || "auto";
const shellToolDisplayMode: TypesGen.AgentDisplayMode =
prefQuery.data?.shell_tool_display_mode || "auto";
const codeDiffDisplayMode: TypesGen.AgentDisplayMode = const codeDiffDisplayMode: TypesGen.AgentDisplayMode =
prefQuery.data?.code_diff_display_mode || "auto"; prefQuery.data?.code_diff_display_mode || "auto";
@@ -367,6 +369,7 @@ export const BlockList: FC<{
name="Tool" name="Tool"
status="running" status="running"
isError={false} isError={false}
shellToolDisplayMode={shellToolDisplayMode}
codeDiffDisplayMode={codeDiffDisplayMode} codeDiffDisplayMode={codeDiffDisplayMode}
subagentTitles={subagentTitles} subagentTitles={subagentTitles}
subagentVariants={subagentVariants} subagentVariants={subagentVariants}
@@ -384,6 +387,7 @@ export const BlockList: FC<{
status={tool.status} status={tool.status}
isError={tool.isError} isError={tool.isError}
killedBySignal={tool.killedBySignal} killedBySignal={tool.killedBySignal}
shellToolDisplayMode={shellToolDisplayMode}
codeDiffDisplayMode={codeDiffDisplayMode} codeDiffDisplayMode={codeDiffDisplayMode}
subagentTitles={subagentTitles} subagentTitles={subagentTitles}
subagentVariants={subagentVariants} subagentVariants={subagentVariants}
@@ -440,6 +444,7 @@ export const BlockList: FC<{
status={tool.status} status={tool.status}
isError={tool.isError} isError={tool.isError}
killedBySignal={tool.killedBySignal} killedBySignal={tool.killedBySignal}
shellToolDisplayMode={shellToolDisplayMode}
codeDiffDisplayMode={codeDiffDisplayMode} codeDiffDisplayMode={codeDiffDisplayMode}
subagentTitles={subagentTitles} subagentTitles={subagentTitles}
subagentVariants={subagentVariants} subagentVariants={subagentVariants}
@@ -564,7 +569,8 @@ const ChatMessageItem = memo<{
) : ( ) : (
<Message className="w-full"> <Message className="w-full">
<MessageContent className="whitespace-normal"> <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 <BlockList
blocks={parsed.blocks} blocks={parsed.blocks}
tools={parsed.tools} tools={parsed.tools}
@@ -1,7 +1,10 @@
import type { Meta, StoryObj } from "@storybook/react-vite"; 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"; 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> = { const meta: Meta<typeof ExecuteTool> = {
title: "components/ai-elements/tool/ExecuteTool", title: "components/ai-elements/tool/ExecuteTool",
component: 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 = { export const LongCommand: Story = {
args: { args: {
command: command: longCommand,
"find /home/coder/project/src -type f -name '*.ts' -not -path '*/node_modules/*' -not -path '*/.git/*' | xargs grep -l 'deprecated' | sort | head -50",
output: "", output: "",
}, },
play: async ({ canvasElement }) => { play: async ({ canvasElement }) => {
const canvas = within(canvasElement); const canvas = within(canvasElement);
const chevron = canvas.getByRole("button", { const command = canvas.getByText(longCommand);
name: /expand command/i, expect(command).toBeVisible();
}); expect(
await userEvent.click(chevron); canvas.queryByRole("button", { name: longCommand }),
// Hover the component so the chevron stays visible. ).not.toBeInTheDocument();
await userEvent.hover(canvasElement.firstElementChild!); await userEvent.hover(command);
expect(
await screen.findByRole("tooltip", undefined, { timeout: 2000 }),
).toHaveTextContent(longCommand);
}, },
}; };
/** A long truncated command with multi-line output below it. */ /** A long truncated command with multi-line output below it. */
export const LongCommandWithOutput: Story = { export const LongCommandWithOutput: Story = {
args: { args: {
command: command: longCommand,
"find /home/coder/project/src -type f -name '*.ts' -not -path '*/node_modules/*' -not -path '*/.git/*' | xargs grep -l 'deprecated' | sort | head -50",
output: [ output: [
"src/api/legacyClient.ts", "src/api/legacyClient.ts",
"src/components/OldTable/OldTable.tsx", "src/components/OldTable/OldTable.tsx",
@@ -9,7 +9,8 @@ import {
TriangleAlertIcon, TriangleAlertIcon,
} from "lucide-react"; } from "lucide-react";
import type React from "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 { Button } from "#/components/Button/Button";
import { CopyButton } from "#/components/CopyButton/CopyButton"; import { CopyButton } from "#/components/CopyButton/CopyButton";
import { ScrollArea } from "#/components/ScrollArea/ScrollArea"; import { ScrollArea } from "#/components/ScrollArea/ScrollArea";
@@ -20,186 +21,210 @@ import {
} from "#/components/Tooltip/Tooltip"; } from "#/components/Tooltip/Tooltip";
import { cn } from "#/utils/cn"; import { cn } from "#/utils/cn";
import { import {
BORDER_BG_STYLE, type AgentDisplayState,
COLLAPSED_OUTPUT_HEIGHT, isAgentDisplayOpen,
resolveAgentDisplayState,
} from "./displayMode";
import {
formatShellDurationMs,
signalTooltipLabel, signalTooltipLabel,
type ToolStatus, type ToolStatus,
} from "./utils"; } from "./utils";
/** type ExecuteToolProps = {
* 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<{
command: string; command: string;
output: string; output: string;
status: ToolStatus; status: ToolStatus;
isError: boolean; isError: boolean;
durationMs?: number;
isBackgrounded?: boolean; isBackgrounded?: boolean;
killedBySignal?: "kill" | "terminate"; killedBySignal?: "kill" | "terminate";
}> = ({ command, output, status, isBackgrounded = false, killedBySignal }) => { shellToolDisplayMode?: TypesGen.AgentDisplayMode;
const [expanded, setExpanded] = useState(false); };
const outputRef = useRef<HTMLPreElement | null>(null);
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 hasOutput = output.length > 0;
const isRunning = status === "running"; const isRunning = status === "running";
const showFailureIndicator = isError && !isRunning;
// Track whether the command text is truncated so we can offer const [outputOpen, setOutputOpen] = useState(outputInitiallyOpen);
// a click-to-expand interaction. The ResizeObserver may clear const outputToggleLabel = outputOpen
// commandOverflows while the text is wrapped, but ? "Collapse command output"
// canToggleCommand stays true via commandExpanded so the : "Expand command output";
// collapse affordance remains visible. const durationLabel = formatShellDurationMs(durationMs);
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);
}
};
return ( return (
<div className="group/exec w-full overflow-hidden rounded-md border border-solid border-border-default bg-surface-primary"> <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">
{/* Header: $ command + copy button */} <Tooltip delayDuration={300}>
<div className="flex w-full items-start justify-between gap-2 px-3 py-2"> <TooltipTrigger asChild>
{/* biome-ignore lint/a11y/useKeyWithClickEvents: Click toggles for mouse users; keyboard users use the chevron button. */} {hasOutput ? (
<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 && (
<button <button
type="button" type="button"
onClick={() => setCommandExpanded((v) => !v)} aria-expanded={outputOpen}
className={cn( aria-label={outputToggleLabel}
"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", onClick={() => setOutputOpen((value) => !value)}
commandExpanded 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"
? "opacity-100"
: "opacity-0 group-hover/exec:opacity-100",
)}
aria-expanded={commandExpanded}
aria-label={
commandExpanded ? "Collapse command" : "Expand command"
}
> >
<ChevronDownIcon <ShellCommandLine
className={cn( command={command}
"h-3.5 w-3.5 transition-transform", durationLabel={durationLabel}
commandExpanded && "rotate-180", expanded={outputOpen}
)}
/> />
</button> </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 && ( </TooltipTrigger>
<LoaderIcon className="h-3.5 w-3.5 shrink-0 animate-spin motion-reduce:animate-none text-content-secondary" /> <TooltipContent className="max-w-xl whitespace-pre-wrap break-all font-mono">
)} {command}
{isBackgrounded && !isRunning && ( </TooltipContent>
<Tooltip> </Tooltip>
<TooltipTrigger asChild> <div className="col-start-2 row-start-1 flex shrink-0 items-center gap-1">
<LayersIcon className="h-3.5 w-3.5 shrink-0 text-content-secondary" /> {isRunning && (
</TooltipTrigger> <LoaderIcon className="h-3.5 w-3.5 shrink-0 animate-spin motion-reduce:animate-none text-content-secondary" />
<TooltipContent>Running in background</TooltipContent> )}
</Tooltip> {showFailureIndicator && (
)} <Tooltip>
{killedBySignal && !isRunning && ( <TooltipTrigger asChild>
<Tooltip> <span
<TooltipTrigger asChild> aria-label="Command failed"
<OctagonXIcon className="size-3.5 shrink-0 text-content-secondary" /> role="img"
</TooltipTrigger> className="flex shrink-0 text-content-destructive"
<TooltipContent> >
{signalTooltipLabel(killedBySignal)} <TriangleAlertIcon
</TooltipContent> aria-hidden
</Tooltip> className="h-3.5 w-3.5 shrink-0"
)} />
<CopyButton </span>
text={command} </TooltipTrigger>
label="Copy command" <TooltipContent>Command failed</TooltipContent>
className="-my-0.5 size-6 p-0 opacity-0 transition-opacity hover:bg-surface-tertiary group-hover/exec:opacity-100" </Tooltip>
/> )}
</div> {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> </div>
{/* Output preview / expanded */} {hasOutput && outputOpen && (
{hasOutput && ( <ShellOutputBody output={output} isError={isError} />
<>
<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>
)}
</>
)} )}
</div> </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<{ export const ExecuteAuthRequiredTool: React.FC<{
command: string; command: string;
output: string; output: string;
@@ -19,7 +19,7 @@ export default meta;
type Story = StoryObj<typeof Tool>; type Story = StoryObj<typeof Tool>;
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Execute tool killed indicator via killedBySignal prop // Execute tool, killed indicator via killedBySignal prop.
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export const ExecuteKilled: Story = { 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 = { export const ExecuteNotSignaled: Story = {
args: { args: {
name: "execute", 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 = { export const ExecuteRunningNotYetKilled: Story = {
args: { args: {
name: "execute", name: "execute",
@@ -96,7 +96,7 @@ export const ExecuteRunningNotYetKilled: Story = {
}; };
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// ProcessOutput tool killed indicator // ProcessOutput tool, killed indicator.
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export const ProcessOutputKilled: Story = { 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 = { export const ProcessOutputKilledNoOutput: Story = {
args: { args: {
name: "process_output", name: "process_output",
@@ -1,6 +1,7 @@
import { ChevronDownIcon, LoaderIcon, OctagonXIcon } from "lucide-react"; import { ChevronDownIcon, LoaderIcon, OctagonXIcon } from "lucide-react";
import type React from "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 { CopyButton } from "#/components/CopyButton/CopyButton";
import { ScrollArea } from "#/components/ScrollArea/ScrollArea"; import { ScrollArea } from "#/components/ScrollArea/ScrollArea";
import { import {
@@ -9,124 +10,152 @@ import {
TooltipTrigger, TooltipTrigger,
} from "#/components/Tooltip/Tooltip"; } from "#/components/Tooltip/Tooltip";
import { cn } from "#/utils/cn"; import { cn } from "#/utils/cn";
import {
type AgentDisplayState,
isAgentDisplayFullyExpanded,
resolveAgentDisplayState,
} from "./displayMode";
import { AgentDisplayModeToolCollapsible } from "./ToolCollapsible";
import { COLLAPSED_OUTPUT_HEIGHT, signalTooltipLabel } from "./utils"; import { COLLAPSED_OUTPUT_HEIGHT, signalTooltipLabel } from "./utils";
/** type ProcessOutputToolProps = {
* 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<{
output: string; output: string;
isRunning: boolean; isRunning: boolean;
exitCode: number | null; exitCode: number | null;
isError: boolean; isError: boolean;
killedBySignal?: "kill" | "terminate"; killedBySignal?: "kill" | "terminate";
}> = ({ output, isRunning, exitCode, isError, killedBySignal }) => { shellToolDisplayMode?: TypesGen.AgentDisplayMode;
const [expanded, setExpanded] = useState(false); };
const outputRef = useRef<HTMLPreElement | null>(null);
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 hasOutput = output.length > 0;
const [overflows, setOverflows] = useState(false); const [overflows, setOverflows] = useState(false);
const measureRef = (node: HTMLPreElement | null) => { const measureRef = (node: HTMLPreElement | null) => {
outputRef.current = node;
if (node) { if (node) {
setOverflows(node.scrollHeight > COLLAPSED_OUTPUT_HEIGHT); setOverflows(node.scrollHeight > COLLAPSED_OUTPUT_HEIGHT);
} }
}; };
const showExitCode = exitCode !== null && exitCode !== 0; const showExitCode = exitCode !== null && exitCode !== 0;
const toggleOutputExpansion = () => {
setOutputFullyExpanded((expanded) => !expanded);
};
const hasHeaderActions =
isRunning || Boolean(killedBySignal) || showExitCode || hasOutput;
return ( return (
<div className="group/proc w-full overflow-hidden rounded-md border border-solid border-border-default bg-surface-primary"> <AgentDisplayModeToolCollapsible
{hasOutput ? ( className="group/proc w-full"
<> hasContent={hasOutput}
<div className="relative"> displayMode={shellToolDisplayMode}
<ScrollArea autoDisplayState={autoDisplayState}
className="text-2xs" ariaLabel={(expanded) =>
viewportClassName={expanded ? "max-h-96" : ""} expanded ? "Collapse process output" : "Expand process output"
scrollBarClassName="w-1.5" }
> header={<span className="text-[13px]">Process output</span>}
<pre headerActions={
ref={measureRef} hasHeaderActions ? (
style={ <>
expanded {isRunning && (
? undefined <LoaderIcon className="h-3.5 w-3.5 shrink-0 animate-spin motion-reduce:animate-none text-content-secondary" />
: { maxHeight: COLLAPSED_OUTPUT_HEIGHT, overflow: "hidden" } )}
} {killedBySignal && !isRunning && (
className={cn( <Tooltip>
"m-0 border-0 whitespace-pre-wrap break-all bg-transparent px-3 py-2 font-mono text-xs", <TooltipTrigger asChild>
isError <OctagonXIcon className="size-3.5 shrink-0 text-content-secondary" />
? "text-content-destructive" </TooltipTrigger>
: "text-content-secondary", <TooltipContent>
)} {signalTooltipLabel(killedBySignal)}
> </TooltipContent>
{output} </Tooltip>
</pre> )}
</ScrollArea> {showExitCode && (
<div className="absolute right-1 top-0.5 flex items-center gap-1 opacity-0 transition-opacity group-hover/proc:opacity-100"> <span className="rounded px-1.5 py-0.5 font-mono text-2xs leading-none bg-surface-red text-content-destructive">
{isRunning && ( exit {exitCode}
<LoaderIcon className="h-3.5 w-3.5 shrink-0 animate-spin motion-reduce:animate-none text-content-secondary" /> </span>
)} )}
{killedBySignal && !isRunning && ( {hasOutput && <CopyButton text={output} label="Copy output" />}
<Tooltip> </>
<TooltipTrigger asChild> ) : undefined
<OctagonXIcon className="size-3.5 shrink-0 text-content-secondary" /> }
</TooltipTrigger> >
<TooltipContent> <ScrollArea
{signalTooltipLabel(killedBySignal)} className="mt-1.5 rounded-md border border-solid border-border-default text-2xs"
</TooltipContent> viewportClassName={outputFullyExpanded ? "max-h-64" : ""}
</Tooltip> scrollBarClassName="w-1.5"
)} >
{showExitCode && ( <pre
<span className="rounded px-1.5 py-0.5 font-mono text-2xs leading-none bg-surface-red text-content-destructive"> ref={measureRef}
exit {exitCode} style={
</span> outputFullyExpanded
)} ? undefined
<CopyButton text={output} label="Copy output" /> : { maxHeight: COLLAPSED_OUTPUT_HEIGHT, overflow: "hidden" }
</div> }
</div> className={cn(
"m-0 border-0 whitespace-pre-wrap break-all bg-transparent px-3 py-2 font-mono text-xs",
{/* Expand / collapse toggle at the bottom */} isError ? "text-content-destructive" : "text-content-secondary",
{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>
)} )}
</> >
) : ( {output}
<div className="flex items-center gap-1 px-3 py-1.5"> </pre>
{isRunning && ( </ScrollArea>
<LoaderIcon className="h-3.5 w-3.5 shrink-0 animate-spin motion-reduce:animate-none text-content-secondary" /> {overflows && (
)} <button
{killedBySignal && !isRunning && ( type="button"
<Tooltip> aria-expanded={outputFullyExpanded}
<TooltipTrigger asChild> onClick={toggleOutputExpansion}
<OctagonXIcon className="size-3.5 shrink-0 text-content-secondary" /> 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"
</TooltipTrigger> aria-label={
<TooltipContent> outputFullyExpanded
{signalTooltipLabel(killedBySignal)} ? "Collapse full process output"
</TooltipContent> : "Expand full process output"
</Tooltip> }
)} >
{showExitCode && ( <ChevronDownIcon
<span className="rounded px-1.5 py-0.5 font-mono text-2xs leading-none bg-surface-red text-content-destructive"> className={cn(
exit {exitCode} "h-3 w-3 transition-transform",
</span> outputFullyExpanded && "rotate-180",
)} )}
</div> />
</button>
)} )}
</div> </AgentDisplayModeToolCollapsible>
); );
}; };
@@ -1,11 +1,21 @@
import type { Meta, StoryObj } from "@storybook/react-vite"; 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 { reactRouterParameters } from "storybook-addon-remix-react-router";
import { ChatWorkspaceContext } from "../../../context/ChatWorkspaceContext"; import { ChatWorkspaceContext } from "../../../context/ChatWorkspaceContext";
import { DesktopPanelContext } from "./DesktopPanelContext"; import { DesktopPanelContext } from "./DesktopPanelContext";
import { Tool } from "./Tool"; import { Tool } from "./Tool";
const executeCommand = "git fetch origin"; 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> = { const meta: Meta<typeof Tool> = {
title: "pages/AgentsPage/ChatElements/tools/Tool", title: "pages/AgentsPage/ChatElements/tools/Tool",
component: Tool, component: Tool,
@@ -46,7 +56,11 @@ export const ExecuteRunning: Story = {
export const ExecuteSuccess: Story = { export const ExecuteSuccess: Story = {
args: { args: {
shellToolDisplayMode: "auto",
args: { command: longExecuteCommand },
result: { result: {
wall_duration_ms: 47200,
exit_code: 0,
output: output:
"From github.com:coder/coder\n * [new branch] feature/agent-ui -> origin/feature/agent-ui", "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 }) => { play: async ({ canvasElement }) => {
const canvas = within(canvasElement); const canvas = within(canvasElement);
expect(canvas.getByText(/From github\.com:coder\/coder/)).toBeVisible(); 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; previousResponseText?: string;
/** Human-readable intent extracted from the model's tool-call args. */ /** Human-readable intent extracted from the model's tool-call args. */
modelIntent?: string; modelIntent?: string;
shellToolDisplayMode?: TypesGen.AgentDisplayMode;
codeDiffDisplayMode?: TypesGen.AgentDisplayMode; codeDiffDisplayMode?: TypesGen.AgentDisplayMode;
} }
@@ -116,6 +117,7 @@ type ToolRendererProps = {
mcpServerConfigId?: string; mcpServerConfigId?: string;
mcpServers?: readonly TypesGen.MCPServerConfig[]; mcpServers?: readonly TypesGen.MCPServerConfig[];
modelIntent?: string; modelIntent?: string;
shellToolDisplayMode?: TypesGen.AgentDisplayMode;
codeDiffDisplayMode?: TypesGen.AgentDisplayMode; codeDiffDisplayMode?: TypesGen.AgentDisplayMode;
}; };
@@ -218,11 +220,19 @@ const ExecuteRenderer: FC<ToolRendererProps> = ({
result, result,
isError, isError,
killedBySignal, killedBySignal,
shellToolDisplayMode,
}) => { }) => {
const parsedArgs = parseArgs(args); const parsedArgs = parseArgs(args);
const command = parsedArgs ? asString(parsedArgs.command) : ""; const command = parsedArgs ? asString(parsedArgs.command) : "";
const rec = asRecord(result); const rec = asRecord(result);
const output = rec ? asString(rec.output).trim() : ""; 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 authRequired = rec ? Boolean(rec.auth_required) : false;
const authenticateURL = rec ? asString(rec.authenticate_url).trim() : ""; const authenticateURL = rec ? asString(rec.authenticate_url).trim() : "";
const providerLabel = toProviderLabel( const providerLabel = toProviderLabel(
@@ -247,7 +257,10 @@ const ExecuteRenderer: FC<ToolRendererProps> = ({
output={output} output={output}
status={status} status={status}
isError={isError} isError={isError}
durationMs={durationMs}
isBackgrounded={isBackgrounded}
killedBySignal={killedBySignal} killedBySignal={killedBySignal}
shellToolDisplayMode={shellToolDisplayMode}
/> />
); );
}; };
@@ -257,13 +270,12 @@ const ProcessOutputRenderer: FC<ToolRendererProps> = ({
result, result,
isError, isError,
killedBySignal, killedBySignal,
shellToolDisplayMode,
}) => { }) => {
const rec = asRecord(result); const rec = asRecord(result);
const output = rec ? asString(rec.output).trim() : ""; const output = rec ? asString(rec.output).trim() : "";
const exitCode = rec const exitCode = rec
? rec.exit_code !== undefined && rec.exit_code !== null ? (asNumber(rec.exit_code, { parseString: true }) ?? null)
? Number(rec.exit_code)
: null
: null; : null;
return ( return (
@@ -273,6 +285,7 @@ const ProcessOutputRenderer: FC<ToolRendererProps> = ({
exitCode={exitCode} exitCode={exitCode}
isError={isError} isError={isError}
killedBySignal={killedBySignal} killedBySignal={killedBySignal}
shellToolDisplayMode={shellToolDisplayMode}
/> />
); );
}; };
@@ -1037,6 +1050,7 @@ export const Tool = memo(
isLatestAskUserQuestion, isLatestAskUserQuestion,
previousResponseText, previousResponseText,
modelIntent, modelIntent,
shellToolDisplayMode,
codeDiffDisplayMode, codeDiffDisplayMode,
ref, ref,
...props ...props
@@ -1044,21 +1058,18 @@ export const Tool = memo(
const Renderer = isSubagentToolName(name) const Renderer = isSubagentToolName(name)
? SubagentRenderer ? SubagentRenderer
: (toolRenderers[name] ?? GenericToolRenderer); : (toolRenderers[name] ?? GenericToolRenderer);
const isShellTool = name === "execute" || name === "process_output";
return ( return (
<div <div
ref={ref} ref={ref}
data-tool-call="" data-tool-call=""
data-shell-tool={isShellTool ? "" : undefined}
className={cn( className={cn(
name === "execute" || isShellTool || name === "propose_plan" || name === "advisor"
name === "process_output" ||
name === "propose_plan" ||
name === "advisor"
? "w-full py-0.5" ? "w-full py-0.5"
: "py-0.5", : "py-0.5",
// Collapse padding between adjacent tool calls so they hug. // Keep back-to-back tool cards visually grouped so stacked tool calls do not look double-spaced.
// Bottom padding is removed on a tool followed by a tool, and
// top padding is removed on a tool preceded by a tool.
"[&:has(+[data-tool-call])]:pb-0", "[&:has(+[data-tool-call])]:pb-0",
"[[data-tool-call]+&]:pt-0", "[[data-tool-call]+&]:pt-0",
className, className,
@@ -1084,6 +1095,7 @@ export const Tool = memo(
isLatestAskUserQuestion={isLatestAskUserQuestion} isLatestAskUserQuestion={isLatestAskUserQuestion}
previousResponseText={previousResponseText} previousResponseText={previousResponseText}
modelIntent={modelIntent} modelIntent={modelIntent}
shellToolDisplayMode={shellToolDisplayMode}
codeDiffDisplayMode={codeDiffDisplayMode} codeDiffDisplayMode={codeDiffDisplayMode}
/> />
</div> </div>
@@ -9,13 +9,16 @@ import {
resolveAgentDisplayState, resolveAgentDisplayState,
} from "./displayMode"; } from "./displayMode";
type ToolCollapsibleAriaLabel = string | ((expanded: boolean) => string);
type ToolCollapsibleHeader = ReactNode | ((expanded: boolean) => ReactNode); type ToolCollapsibleHeader = ReactNode | ((expanded: boolean) => ReactNode);
interface ToolCollapsibleProps { interface ToolCollapsibleProps {
children: ReactNode; children: ReactNode;
header: ToolCollapsibleHeader; header: ToolCollapsibleHeader;
headerActions?: ReactNode;
hasContent?: boolean; hasContent?: boolean;
defaultExpanded?: boolean; defaultExpanded?: boolean;
ariaLabel?: ToolCollapsibleAriaLabel;
className?: string; className?: string;
headerClassName?: string; headerClassName?: string;
} }
@@ -30,6 +33,7 @@ export const AgentDisplayModeToolCollapsible: FC<
AgentDisplayModeToolCollapsibleProps AgentDisplayModeToolCollapsibleProps
> = ({ displayMode, autoDisplayState, ...props }) => { > = ({ displayMode, autoDisplayState, ...props }) => {
const displayState = resolveAgentDisplayState(displayMode, autoDisplayState); const displayState = resolveAgentDisplayState(displayMode, autoDisplayState);
return ( return (
<ToolCollapsible <ToolCollapsible
key={`${displayMode ?? "auto"}:${autoDisplayState}`} key={`${displayMode ?? "auto"}:${autoDisplayState}`}
@@ -42,45 +46,63 @@ export const AgentDisplayModeToolCollapsible: FC<
export const ToolCollapsible: FC<ToolCollapsibleProps> = ({ export const ToolCollapsible: FC<ToolCollapsibleProps> = ({
children, children,
header, header,
headerActions,
hasContent = true, hasContent = true,
defaultExpanded = false, defaultExpanded = false,
ariaLabel,
className, className,
headerClassName, headerClassName,
}) => { }) => {
const [expanded, setExpanded] = useState(defaultExpanded); const [expanded, setExpanded] = useState(defaultExpanded);
const renderedHeader = const renderedHeader =
typeof header === "function" ? header(expanded) : header; 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 ( return (
<div className={className}> <div className={className}>
{hasContent ? ( {headerActions ? (
<button <div className="flex w-full items-center gap-2">
type="button" {headerButton}
aria-expanded={expanded} <div className="flex shrink-0 items-center gap-1">
onClick={() => setExpanded(!expanded)} {headerActions}
className={cn( </div>
"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}
</div> </div>
) : (
headerButton
)} )}
{expanded && hasContent && children} {expanded && hasContent && children}
</div> </div>
@@ -1,6 +1,5 @@
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { import {
BORDER_BG_STYLE,
buildEditDiff, buildEditDiff,
buildWriteFileDiff, buildWriteFileDiff,
COLLAPSED_OUTPUT_HEIGHT, COLLAPSED_OUTPUT_HEIGHT,
@@ -9,6 +8,7 @@ import {
diffViewerCSS, diffViewerCSS,
fileViewerCSS, fileViewerCSS,
formatResultOutput, formatResultOutput,
formatShellDurationMs,
getDiffViewerOptions, getDiffViewerOptions,
getFileContentForViewer, getFileContentForViewer,
getFileViewerOptions, getFileViewerOptions,
@@ -70,20 +70,45 @@ describe("shortDurationMs", () => {
expect(shortDurationMs(1000)).toBe("1s"); expect(shortDurationMs(1000)).toBe("1s");
expect(shortDurationMs(30_000)).toBe("30s"); expect(shortDurationMs(30_000)).toBe("30s");
expect(shortDurationMs(59_000)).toBe("59s"); expect(shortDurationMs(59_000)).toBe("59s");
expect(shortDurationMs(59_499)).toBe("59s");
}); });
it("formats minutes", () => { it("formats minutes", () => {
expect(shortDurationMs(59_500)).toBe("1m");
expect(shortDurationMs(60_000)).toBe("1m"); expect(shortDurationMs(60_000)).toBe("1m");
expect(shortDurationMs(300_000)).toBe("5m"); expect(shortDurationMs(300_000)).toBe("5m");
expect(shortDurationMs(3_540_000)).toBe("59m"); expect(shortDurationMs(3_540_000)).toBe("59m");
expect(shortDurationMs(3_569_999)).toBe("59m");
}); });
it("formats hours", () => { it("formats hours", () => {
expect(shortDurationMs(3_570_000)).toBe("1h");
expect(shortDurationMs(3_600_000)).toBe("1h"); expect(shortDurationMs(3_600_000)).toBe("1h");
expect(shortDurationMs(7_200_000)).toBe("2h"); 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", () => { describe("normalizeStatus", () => {
it("lowercases and trims", () => { it("lowercases and trims", () => {
expect(normalizeStatus(" COMPLETED ")).toBe("completed"); expect(normalizeStatus(" COMPLETED ")).toBe("completed");
@@ -840,13 +865,6 @@ describe("constants", () => {
expect(DIFFS_FONT_STYLE).toHaveProperty("--diffs-line-height", "1.5"); 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", () => { it("fileViewerCSS is a non-empty string", () => {
expect(typeof fileViewerCSS).toBe("string"); expect(typeof fileViewerCSS).toBe("string");
expect(fileViewerCSS.length).toBeGreaterThan(0); expect(fileViewerCSS.length).toBeGreaterThan(0);
@@ -55,11 +55,38 @@ export const shortDurationMs = (durationMs: number | undefined): string => {
if (seconds < 60) { if (seconds < 60) {
return `${seconds}s`; return `${seconds}s`;
} }
const minutes = Math.round(seconds / 60); const minutes = Math.round(durationMs / 60_000);
if (minutes < 60) { if (minutes < 60) {
return `${minutes}m`; 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`; return `${hours}h`;
}; };
@@ -439,10 +466,6 @@ export const DIFFS_FONT_STYLE = {
"--diffs-line-height": "1.5", "--diffs-line-height": "1.5",
} as CSSProperties; } as CSSProperties;
export const BORDER_BG_STYLE = {
background: "hsl(var(--border-default))",
};
/** /**
* Checks whether a tool result should be rendered as a syntax-highlighted * Checks whether a tool result should be rendered as a syntax-highlighted
* file viewer. Returns the file path, content, and whether the header * 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 = () => { export const CodeDiffDisplaySettings: FC = () => {
return ( return (
<DisplayModeSettings <DisplayModeSettings
@@ -215,6 +215,7 @@ export const EnablingTaskNotificationClearsAlertDismissal: Story = {
data: { data: {
task_notification_alert_dismissed: true, task_notification_alert_dismissed: true,
thinking_display_mode: "auto" as const, thinking_display_mode: "auto" as const,
shell_tool_display_mode: "auto" as const,
code_diff_display_mode: "auto" as const, code_diff_display_mode: "auto" as const,
agent_chat_send_shortcut: "enter" as const, agent_chat_send_shortcut: "enter" as const,
}, },
@@ -240,6 +241,7 @@ export const EnablingTaskNotificationClearsAlertDismissal: Story = {
).mockResolvedValue({ ).mockResolvedValue({
task_notification_alert_dismissed: false, task_notification_alert_dismissed: false,
thinking_display_mode: "auto", thinking_display_mode: "auto",
shell_tool_display_mode: "auto",
code_diff_display_mode: "auto", code_diff_display_mode: "auto",
agent_chat_send_shortcut: "enter" as const, agent_chat_send_shortcut: "enter" as const,
}); });