diff --git a/docs/ui-spec.md b/docs/ui-spec.md index 4b68738..920429f 100644 --- a/docs/ui-spec.md +++ b/docs/ui-spec.md @@ -204,6 +204,22 @@ - Диалог и «Состояние» — отдельные панели (chat_panel, state_panel), здесь не участвуют; окно оркестрирует загрузку всех панелей по выбору задачи. +### 12.12. Контракт виджета «Диалог» (ChatPanel) + +Диалог — транскрипт общения с выбранной задачей + композитор (поле ввода и +кнопки команд). + +- Транскрипт: + - `SetTranscript(text)` — полный рендер истории выбранной задачи; + - `Append(text)` — добавить строку; при превышении лимита старые строки + отбрасываются (буфер ограничен); + - `Clear()` — очистить при недоступной задаче. +- Композитор: поле ввода + кнопки команд, подключённые к `ui.Commands` через + `SetCommands(c)` (ввод → `SendText`, кнопки → Start/Approve/Skip/Cancel). +- Доставка: окно (как chat.Channel) рендерит `Send`/`Ask`/историю через + `Append`; строка роли = pure `FormatRole(role)` (👤/🤖). +- Обновление — на потоке Fyne; вызывающий уже внутри `fyne.Do`. + --- ## Открытые пункты (TODO) diff --git a/internal/ui/chat_panel.go b/internal/ui/chat_panel.go new file mode 100644 index 0000000..80364b3 --- /dev/null +++ b/internal/ui/chat_panel.go @@ -0,0 +1,39 @@ +package ui + +// ChatPanel — панель «Диалог» (спец 12.12): транскрипт диалога выбранной +// задачи + композитор (поле ввода и кнопки команд). +// +// Транскрипт — буфер ограниченного размера; при превышении лимита старые +// строки отбрасываются. Композитор подключается к ui.Commands через +// SetCommands. Обновление — на потоке Fyne; вызывающий уже внутри fyne.Do. +type ChatPanel interface { + // SetTranscript заменяет содержимое диалога полным рендером. + SetTranscript(text string) + // Append добавляет строку в диалог (буфер ограничен). + Append(text string) + // Clear очищает диалог (задача недоступна/не выбрана). + Clear() + // SetCommands подключает команды к композитору (ввод + кнопки). + SetCommands(c *Commands) +} + +// NilChatPanel — no-op реализация ChatPanel для headless-режима и тестов. +type NilChatPanel struct{} + +// NewNilChatPanel создаёт NilChatPanel. +func NewNilChatPanel() *NilChatPanel { return &NilChatPanel{} } + +func (NilChatPanel) SetTranscript(string) {} +func (NilChatPanel) Append(string) {} +func (NilChatPanel) Clear() {} +func (NilChatPanel) SetCommands(*Commands) {} + +// FormatRole — строка роли в диалоге (pure-функция, спец 12.12). +func FormatRole(role string) string { + switch role { + case "user": + return "👤 " + default: + return "🤖 " + } +} \ No newline at end of file diff --git a/internal/ui/chat_panel_test.go b/internal/ui/chat_panel_test.go new file mode 100644 index 0000000..d076c20 --- /dev/null +++ b/internal/ui/chat_panel_test.go @@ -0,0 +1,26 @@ +package ui + +import "testing" + +// FormatRole — строка роли в диалоге (спец 12.12), pure-функция без Fyne. +func TestFormatRole(t *testing.T) { + if FormatRole("user") != "👤 " { + t.Errorf("user: got %q, want «👤 »", FormatRole("user")) + } + if FormatRole("assistant") != "🤖 " { + t.Errorf("assistant: got %q, want «🤖 »", FormatRole("assistant")) + } + if FormatRole("") != "🤖 " { + t.Errorf("default: got %q, want «🤖 »", FormatRole("")) + } +} + +// NilChatPanel — no-op контракт (спец 12.8): безопасно для headless/тестов. +func TestNilChatPanelNoop(t *testing.T) { + p := NewNilChatPanel() + c := NewCommands(nil) + p.SetTranscript("история") + p.Append("строка") + p.Clear() + p.SetCommands(c) +} \ No newline at end of file diff --git a/internal/ui/desktop/chat_panel.go b/internal/ui/desktop/chat_panel.go new file mode 100644 index 0000000..8c9f007 --- /dev/null +++ b/internal/ui/desktop/chat_panel.go @@ -0,0 +1,87 @@ +//go:build cgo + +package desktop + +import ( + "strings" + + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/container" + "fyne.io/fyne/v2/widget" + + "github.com/kamelion/ratatoskr-go/internal/ui" +) + +// chatLimit — обрезка буфера транскрипта диалога (спец 12.12). +const chatLimit = 200_000 + +// ChatPanel — Fyne-реализация ui.ChatPanel (спец 12.8, 12.12): транскрипт +// диалога + композитор (поле ввода и кнопки команд). +type ChatPanel struct { + convLbl *widget.Label + input *widget.Entry + commands *ui.Commands +} + +// NewChatPanel создаёт ChatPanel. +func NewChatPanel() *ChatPanel { + p := &ChatPanel{} + p.convLbl = widget.NewLabel("Выберите задачу.") + p.convLbl.Wrapping = fyne.TextWrapWord + p.input = widget.NewEntry() + p.input.SetPlaceHolder("Сообщение… (Enter — отправить)") + return p +} + +// Transcript возвращает виджет транскрипта (Scroll) для встраивания в окно. +func (p *ChatPanel) Transcript() fyne.CanvasObject { + return container.NewScroll(p.convLbl) +} + +// Composer возвращает виджет композитора (ввод + кнопки команд). +func (p *ChatPanel) Composer() fyne.CanvasObject { + cmdBar := container.NewHBox( + widget.NewButton("Новая", func() { if p.commands != nil { p.commands.Start() } }), + widget.NewButton("Создавай", func() { if p.commands != nil { p.commands.Approve() } }), + widget.NewButton("Пропустить", func() { if p.commands != nil { p.commands.Skip() } }), + widget.NewButton("Отмена", func() { if p.commands != nil { p.commands.Cancel() } }), + ) + return container.NewBorder(nil, nil, cmdBar, p.input, nil) +} + +// SetTranscript заменяет содержимое диалога (спец 12.12). +func (p *ChatPanel) SetTranscript(text string) { + p.convLbl.SetText(text) +} + +// Append добавляет строку в диалог (буфер ограничен). +func (p *ChatPanel) Append(text string) { + s := p.convLbl.Text + text + "\n\n" + if len(s) > chatLimit { + s = s[len(s)-chatLimit:] + } + p.convLbl.SetText(s) +} + +// Clear очищает диалог. +func (p *ChatPanel) Clear() { + p.convLbl.SetText("") +} + +// SetCommands подключает команды к композитору. +func (p *ChatPanel) SetCommands(c *ui.Commands) { + p.commands = c + p.input.OnSubmitted = func(s string) { + s = strings.TrimSpace(s) + if s == "" { + return + } + p.input.SetText("") + if c != nil { + c.SendText(s) + } + } +} + +// compile-time проверка реализации контракта. +var _ ui.ChatPanel = (*ChatPanel)(nil) \ No newline at end of file diff --git a/internal/ui/desktop/window.go b/internal/ui/desktop/window.go index ebedeca..3676783 100644 --- a/internal/ui/desktop/window.go +++ b/internal/ui/desktop/window.go @@ -24,7 +24,7 @@ import ( ) const ( - bufLimit = 200_000 // обрезка буферов панелей окна (conv/state), чтобы не расти бесконечно + bufLimit = 200_000 // обрезка буфера панели «Состояние», чтобы не расти бесконечно selectedPref = "task.selected" splitHPref = "split.h" splitVPref = "split.v" @@ -45,10 +45,9 @@ type Window struct { // виджеты taskList *TaskListPanel detail *TaskDetailPanel - convLbl *widget.Label + chat *ChatPanel stateLbl *widget.Label logs *LogPanel - input *widget.Entry hsplit *container.Split vsplit *container.Split @@ -83,8 +82,8 @@ func (w *Window) SetOnQuit(fn func()) { w.onQuit = fn } func (w *Window) build() { w.detail = NewTaskDetailPanel() - w.convLbl = widget.NewLabel("Выберите задачу.") - w.convLbl.Wrapping = fyne.TextWrapWord + w.chat = NewChatPanel() + w.chat.SetCommands(w.commands) w.logs = NewLogPanel() @@ -105,10 +104,9 @@ func (w *Window) build() { // Рабочая область: детали сверху, диалог снизу (сплит 2×2 в плане). details := w.detail.Widget() - convScroll := container.NewScroll(w.convLbl) right := container.NewVSplit( container.NewScroll(details), - convScroll, + w.chat.Transcript(), ) w.vsplit = right @@ -118,25 +116,7 @@ func (w *Window) build() { container.NewTabItem("Состояние", container.NewScroll(w.stateLbl)), ) - // Ввод + кнопки команд - w.input = widget.NewEntry() - w.input.SetPlaceHolder("Сообщение… (Enter — отправить)") - w.input.OnSubmitted = func(s string) { - s = strings.TrimSpace(s) - if s == "" { - return - } - w.input.SetText("") - w.commands.SendText(s) - } - cmdBar := container.NewHBox( - widget.NewButton("Новая", func() { w.commands.Start() }), - widget.NewButton("Создавай", func() { w.commands.Approve() }), - widget.NewButton("Пропустить", func() { w.commands.Skip() }), - widget.NewButton("Отмена", func() { w.commands.Cancel() }), - ) - inputRow := container.NewBorder(nil, nil, cmdBar, w.input, nil) - bottom := container.NewBorder(nil, inputRow, nil, nil, bottomTabs) + bottom := container.NewBorder(nil, w.chat.Composer(), nil, nil, bottomTabs) w.hsplit = container.NewHSplit(left, right) @@ -170,7 +150,7 @@ func (w *Window) selectTask(id int64) { t, err := w.store.GetTask(ctx, id) if err != nil { w.detail.ShowEmpty() - w.convLbl.SetText("") + w.chat.Clear() return } w.detail.ShowTask(t) @@ -182,7 +162,7 @@ func (w *Window) selectTask(id int64) { } var b strings.Builder for _, h := range hist { - b.WriteString(formatRole(h.Role)) + b.WriteString(ui.FormatRole(h.Role)) b.WriteString(h.Content) b.WriteString("\n\n") } @@ -195,21 +175,12 @@ func (w *Window) selectTask(id int64) { if b.Len() == 0 { b.WriteString("Нет сообщений. /start — начать задачу.") } - w.convLbl.SetText(b.String()) + w.chat.SetTranscript(b.String()) // Состояние: сброс к снимку задач. w.refreshState(ctx, id) } -func formatRole(role string) string { - switch role { - case "user": - return "👤 " - default: - return "🤖 " - } -} - // refreshState — снимок «Состояния» выбранной задачи (агенты + трейсы). func (w *Window) refreshState(ctx context.Context, id int64) { traces, err := w.store.GetTraces(ctx, id) @@ -242,15 +213,6 @@ func (w *Window) refreshList() { w.taskList.Select(w.selected) } -// appendConv добавляет строку в диалог (буфер ограничен). -func (w *Window) appendConv(text string) { - s := w.convLbl.Text + text + "\n\n" - if len(s) > bufLimit { - s = s[len(s)-bufLimit:] - } - w.convLbl.SetText(s) -} - // appendLog добавляет строку в панель «Логи» (делегирует в LogPanel). func (w *Window) appendLog(text string) { w.logs.Append(text) @@ -306,7 +268,7 @@ func (w *Window) Send(_ context.Context, _ chat.Address, m chat.Message) error { } text = sb.String() } - fyne.Do(func() { w.appendConv("🤖 " + text) }) + fyne.Do(func() { w.chat.Append("🤖 " + text) }) return nil } @@ -316,7 +278,7 @@ func (w *Window) Ask(_ context.Context, _ chat.Address, m chat.Message) error { for _, opt := range m.Options { text += "\n" + opt.Label } - fyne.Do(func() { w.appendConv("❓ " + text) }) + fyne.Do(func() { w.chat.Append("❓ " + text) }) return nil } @@ -370,7 +332,7 @@ func (w *Window) OnTaskStatusChanged(e events.TaskStatusChanged) { func (w *Window) OnHistoryAppended(e events.HistoryAppended) { fyne.Do(func() { if w.selected == e.TaskID { - w.appendConv(formatRole(e.Role) + e.Content) + w.chat.Append(ui.FormatRole(e.Role) + e.Content) } }) }