main 59bf5a6

ask_user_question.go RawHistoryFolder

1package tools
2
3import (
4	"bytes"
5	"context"
6	"encoding/json"
7	"errors"
8	"fmt"
9	"io"
10	"strings"
11	"unicode/utf8"
12
13	"github.com/bnema/zut/packages/core"
14	"github.com/bnema/zut/packages/provider"
15)
16
17const AskUserQuestionToolName = "ask_user_question"
18
19type Question struct {
20	Header      string           `json:"header"`
21	Question    string           `json:"question"`
22	Options     []QuestionOption `json:"options"`
23	MultiSelect bool             `json:"multi_select"`
24}
25type QuestionOption struct {
26	Label       string `json:"label"`
27	Description string `json:"description"`
28}
29type QuestionAnswer struct {
30	Selected []string
31	Custom   string
32}
33
34var ErrQuestionsDeclined = errors.New("user declined to answer")
35
36type QuestionAsker interface {
37	AskQuestions(context.Context, []Question) ([]QuestionAnswer, error)
38}
39type AskUserQuestionTool struct{ Asker func() QuestionAsker }
40
41func (*AskUserQuestionTool) Name() string      { return AskUserQuestionToolName }
42func (*AskUserQuestionTool) Interactive() bool { return true }
43func (*AskUserQuestionTool) Description() string {
44	return "Ask the user up to 10 structured questions in an interactive terminal. Offer up to 9 options per question, or omit options for free text. The user can always provide a custom answer or decline."
45}
46func (*AskUserQuestionTool) Schema() json.RawMessage {
47	return json.RawMessage(`{"type":"object","properties":{"questions":{"type":"array","minItems":1,"maxItems":10,"items":{"type":"object","properties":{"header":{"type":"string","maxLength":16},"question":{"type":"string","minLength":1},"options":{"type":"array","maxItems":9,"items":{"type":"object","properties":{"label":{"type":"string","minLength":1},"description":{"type":"string"}},"required":["label"],"additionalProperties":false}},"multi_select":{"type":"boolean"}},"required":["question"],"additionalProperties":false}}},"required":["questions"],"additionalProperties":false}`)
48}
49func (t *AskUserQuestionTool) Execute(ctx context.Context, raw json.RawMessage, _ func(string)) (core.ToolResult, error) {
50	fail := func(s string) (core.ToolResult, error) {
51		return core.ToolResult{IsError: true, Content: []provider.Content{provider.TextBlock{Text: s}}}, nil
52	}
53	// Null is not a valid value for any field in this schema. encoding/json
54	// otherwise silently accepts null for strings and booleans.
55	var shape any
56	if err := json.Unmarshal(raw, &shape); err == nil && questionContainsNull(shape) {
57		return fail("Invalid questions: null fields are not allowed")
58	}
59	var args struct {
60		Questions []Question `json:"questions"`
61	}
62	d := json.NewDecoder(bytes.NewReader(raw))
63	d.DisallowUnknownFields()
64	if err := d.Decode(&args); err != nil {
65		return fail("Invalid questions: " + err.Error())
66	}
67	if d.Decode(&struct{}{}) != io.EOF {
68		return fail("Invalid questions: expected one JSON object")
69	}
70	if len(args.Questions) < 1 || len(args.Questions) > 10 {
71		return fail("questions must contain 1 to 10 questions")
72	}
73	for _, q := range args.Questions {
74		if strings.TrimSpace(q.Question) == "" || utf8.RuneCountInString(q.Header) > 16 || len(q.Options) > 9 {
75			return fail("Each question needs text, a header of at most 16 characters, and at most 9 options")
76		}
77		seen := map[string]bool{}
78		for _, o := range q.Options {
79			label := strings.TrimSpace(o.Label)
80			if label == "" || seen[label] {
81				return fail("Option labels must be non-empty and unique")
82			}
83			seen[label] = true
84		}
85	}
86	if err := ctx.Err(); err != nil {
87		return core.ToolResult{}, err
88	}
89	var asker QuestionAsker
90	if t.Asker != nil {
91		asker = t.Asker()
92	}
93	if asker == nil {
94		return fail("ask_user_question needs an interactive terminal session")
95	}
96	answers, err := asker.AskQuestions(ctx, args.Questions)
97	if ctx.Err() != nil {
98		return core.ToolResult{}, ctx.Err()
99	}
100	if errors.Is(err, ErrQuestionsDeclined) {
101		return core.ToolResult{Content: []provider.Content{provider.TextBlock{Text: "The user declined to answer the questions."}}}, nil
102	}
103	if err != nil {
104		return fail(err.Error())
105	}
106	if len(answers) != len(args.Questions) {
107		return fail("Interactive host returned an incomplete answer set")
108	}
109	var b strings.Builder
110	for n, q := range args.Questions {
111		a := answers[n]
112		text := strings.Join(a.Selected, ", ")
113		if a.Custom != "" {
114			text = a.Custom + " (custom)"
115		}
116		fmt.Fprintf(&b, "Q: %s\nA: %s\n\n", q.Question, text)
117	}
118	return core.ToolResult{Content: []provider.Content{provider.TextBlock{Text: strings.TrimSpace(b.String())}}}, nil
119}
120
121func questionContainsNull(v any) bool {
122	if v == nil {
123		return true
124	}
125	switch x := v.(type) {
126	case map[string]any:
127		for _, child := range x {
128			if questionContainsNull(child) {
129				return true
130			}
131		}
132	case []any:
133		for _, child := range x {
134			if questionContainsNull(child) {
135				return true
136			}
137		}
138	}
139	return false
140}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 477

↑↓ moveenter openesc close