Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 37 additions & 19 deletions cmd/ax/exec.go
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,7 @@ func execLoop(ctx context.Context, id string, harnessID string, harnessConfig []
if !execResume {
var quit bool
var err error
input, quit, err = promptUser(d, input)
input, harnessConfig, quit, err = promptUser(d, input, harnessConfig)
if err != nil {
return err
}
Expand Down Expand Up @@ -260,8 +260,11 @@ func execLoop(ctx context.Context, id string, harnessID string, harnessConfig []
}
}

// Per-request config: clear the config after each turn.
harnessConfig = nil

var quit bool
input, quit, err = promptUser(d, "")
input, harnessConfig, quit, err = promptUser(d, "", harnessConfig)
if err != nil {
return err
}
Expand Down Expand Up @@ -363,32 +366,47 @@ func displayContents(d *internal.Display, contents []*proto.Message) {
}

// promptUser loops until the user provides a non-empty input string.
// The "/config" command opens the harness config menu.
// It returns:
// - string: the valid user input
// - []byte: the (possibly updated) harness config
// - bool: true if the user entered a quit command
// - error: any error that occurred during prompting
func promptUser(d *internal.Display, input string) (string, bool, error) {
for strings.TrimSpace(input) == "" {
var err error
input, err = d.PromptForInput()
if err != nil {
if errors.Is(err, internal.ErrUserAborted) {
if interruptHandler.HandleInterrupt() {
return "", true, nil
func promptUser(d *internal.Display, input string, harnessConfig []byte) (string, []byte, bool, error) {
for {
for strings.TrimSpace(input) == "" {
var err error
input, err = d.PromptForInput()
if err != nil {
if errors.Is(err, internal.ErrUserAborted) {
if interruptHandler.HandleInterrupt() {
return "", harnessConfig, true, nil
}
input = "" // Continue loop to prompt again
continue
}
input = "" // Continue loop to prompt again
continue
return "", harnessConfig, false, err
}
return "", false, err
}
}

d.DisplayInput(input)
if strings.ToLower(strings.TrimSpace(input)) == "q" {
d.ShowResumption(execConversationID, execServerAddr)
return "", true, nil
trimmed := strings.TrimSpace(input)
if trimmed == "/config" {
cfg, err := runConfigMenu(d, harnessConfig)
if err != nil {
return "", harnessConfig, false, err
}
harnessConfig = cfg
input = "" // Re-prompt after handling the config.
continue
}

d.DisplayInput(input)
if strings.ToLower(trimmed) == "q" {
d.ShowResumption(execConversationID, execServerAddr)
return "", harnessConfig, true, nil
}
return input, harnessConfig, false, nil
}
return input, false, nil
}

// InterruptHandler encapsulates the cancellation and signal handling state.
Expand Down
138 changes: 138 additions & 0 deletions cmd/ax/harnessconfig.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package main

import (
"bytes"
"encoding/json"
"errors"
"fmt"
"os"
"strings"

"github.com/google/ax/cmd/ax/internal"
)

// runConfigMenu shows the /config menu and returns the (possibly updated) config.
// An updated config is sent on subsequent requests.
func runConfigMenu(d *internal.Display, harnessConfig []byte) ([]byte, error) {
for {
action, err := d.PromptForConfigAction()
if err != nil {
if errors.Is(err, internal.ErrUserAborted) {
return harnessConfig, nil // Esc/Ctrl+C on the menu cancels /config.
}
return harnessConfig, err
}

switch action {
case "edit":
cfg, done, err := editHarnessConfig(d, harnessConfig)
if err != nil {
return harnessConfig, err
}
if done {
return cfg, nil
}
case "load":
cfg, done, err := loadHarnessConfig(d)
if err != nil {
return harnessConfig, err
}
if done {
return cfg, nil
}
default: // "cancel" or anything else
return harnessConfig, nil
}
}
}

// editHarnessConfig opens the JSON editor pre-filled with the current config. It
// returns the updated config with done=true if the config was updated, or
// done=false (config ignored) if the user cancelled back to the menu. Invalid
// JSON is reported and the editor re-opens with the user's draft so they can fix
// it.
func editHarnessConfig(d *internal.Display, harnessConfig []byte) ([]byte, bool, error) {
draft := prettyHarnessConfig(harnessConfig)
for {
edited, err := d.PromptForConfigEdit(draft)
if err != nil {
if errors.Is(err, internal.ErrUserAborted) {
return nil, false, nil // Back to the menu.
}
return nil, false, err
}
normalized, err := normalizeHarnessConfigJSON(edited)
if err != nil {
d.ShowNotice(fmt.Sprintf("Invalid config: %v", err))
draft = edited // Preserve the user's input so they can fix it.
continue
}
return normalized, true, nil
}
}

// loadHarnessConfig lets the user pick a JSON file and loads it. It returns the
// loaded config with done=true, or done=false (config ignored) if the user
// cancelled back to the menu or the file could not be used.
func loadHarnessConfig(d *internal.Display) ([]byte, bool, error) {
path, err := d.PromptForConfigFile()
if err != nil {
if errors.Is(err, internal.ErrUserAborted) {
return nil, false, nil // Back to the menu.
}
return nil, false, err
}
b, err := os.ReadFile(strings.TrimSpace(path))
if err != nil {
d.ShowNotice(fmt.Sprintf("Failed to read file: %v", err))
return nil, false, nil
}
normalized, err := normalizeHarnessConfigJSON(string(b))
if err != nil {
d.ShowNotice(fmt.Sprintf("Invalid config: %v", err))
return nil, false, nil
}
return normalized, true, nil
}

// normalizeHarnessConfigJSON trims and validates the given JSON config, returning
// the bytes to send on the wire. Empty input clears the config (returns nil). The
// config must be a JSON object.
func normalizeHarnessConfigJSON(s string) ([]byte, error) {
s = strings.TrimSpace(s)
if s == "" {
return nil, nil
}
var obj map[string]any
if err := json.Unmarshal([]byte(s), &obj); err != nil {
return nil, err
}
return []byte(s), nil
}

// prettyHarnessConfig returns an indented JSON rendering of the config bytes for
// display, falling back to the raw bytes if they cannot be parsed.
func prettyHarnessConfig(b []byte) string {
if len(b) == 0 {
return ""
}
var buf bytes.Buffer
if err := json.Indent(&buf, b, "", " "); err != nil {
return string(b)
}
return buf.String()
}
81 changes: 81 additions & 0 deletions cmd/ax/harnessconfig_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package main

import (
"strings"
"testing"
)

func TestNormalizeHarnessConfigJSON(t *testing.T) {
tests := []struct {
name string
in string
want string
wantErr bool
}{
{name: "empty", in: "", want: ""},
{name: "whitespace clears", in: " \n\t ", want: ""},
{name: "valid object", in: `{"model":"gemini"}`, want: `{"model":"gemini"}`},
{name: "trims surrounding whitespace", in: " {\"model\":\"gemini\"}\n", want: `{"model":"gemini"}`},
{name: "nested object", in: `{"a":{"b":1},"c":[1,2]}`, want: `{"a":{"b":1},"c":[1,2]}`},
{name: "invalid json", in: `{bad}`, wantErr: true},
{name: "non-object array", in: `[1,2,3]`, wantErr: true},
{name: "non-object scalar", in: `42`, wantErr: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := normalizeHarnessConfigJSON(tt.in)
if tt.wantErr {
if err == nil {
t.Fatalf("expected error, got nil (result %q)", got)
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if string(got) != tt.want {
t.Errorf("got %q, want %q", got, tt.want)
}
if tt.want == "" && got != nil {
t.Errorf("expected nil for cleared config, got %q", got)
}
})
}
}

func TestPrettyHarnessConfig(t *testing.T) {
if got := prettyHarnessConfig(nil); got != "" {
t.Errorf("nil: got %q, want empty string", got)
}
if got := prettyHarnessConfig([]byte{}); got != "" {
t.Errorf("empty: got %q, want empty string", got)
}

// Invalid JSON falls back to the raw bytes.
if got := prettyHarnessConfig([]byte("not json")); got != "not json" {
t.Errorf("invalid: got %q, want raw passthrough", got)
}

// Valid JSON is rendered multi-line and indented.
got := prettyHarnessConfig([]byte(`{"model":"gemini"}`))
if !strings.Contains(got, "model") || !strings.Contains(got, "gemini") {
t.Errorf("valid: got %q, want it to contain the key and value", got)
}
if !strings.Contains(got, "\n") {
t.Errorf("valid: got %q, want indented multi-line output", got)
}
}
Loading
Loading