From bd7e13385dd6917b0c81eb716a74576eebc2c104 Mon Sep 17 00:00:00 2001 From: Tom Date: Mon, 27 Jul 2026 20:06:22 +0200 Subject: [PATCH] Update 0.2.0 - siehe Changelog --- CHANGELOG.md | 25 ++++++++++++ internal/llm/openai/request.go | 6 ++- internal/llm/openai/wire.go | 17 ++++---- internal/ui/tui/commands.go | 6 ++- internal/ui/tui/model.go | 75 ++++++++++++++++++++++++++-------- internal/ui/tui/model_test.go | 10 +++-- internal/ui/tui/render.go | 20 +++++++++ test.py | 8 ++++ 8 files changed, 137 insertions(+), 30 deletions(-) create mode 100644 test.py diff --git a/CHANGELOG.md b/CHANGELOG.md index c763f05..587e10a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,31 @@ Dieses Projekt ist privat und folgt keinem formalen Versionsschema ([SemVer](https://semver.org/lang/de/)) — Versionsnummern hier dienen nur der zeitlichen Einordnung. +## [0.2.0] - 2026-07-25 + +### Behoben + +- TUI: Markdown/Syntax-Highlighting einer Antwort erschien teils erst nach + einem Fenster-Resize, weil nach Abschluss eines Turns (`TurnDone`) das + Viewport nicht neu gerendert wurde. +- TUI: Statuszeile zeigte während laufendem Tool-Output (`bash` & Co.) die + Tool-Call-ID statt des Tool-Namens an. +- TUI: Statuszeile überschrieb bei parallelen Tool-Calls den Namen des einen + mit dem des anderen — zeigt jetzt alle gerade laufenden Tools an. +- OpenAI-Adapter: Reasoning-Modelle (z.B. `o1`) senden jetzt + `max_completion_tokens` statt `max_tokens`, wie von der API verlangt. + +### Geändert + +- TUI: Fließtext (Assistant/User/Thinking/...) wird auf max. 100 Spalten + gewrappt statt auf volle Terminalbreite — bessere Lesbarkeit bei breiten + Terminals. Tool-Output/Diffs behalten die volle Breite. +- TUI: Tool-Call und -Ergebnis werden jetzt als ein kompakter Block + angezeigt (Ergebnis ersetzt den wartenden Call-Eintrag) statt als zwei + separate Blöcke im Transkript. +- TUI: Eingabefeld-Platzhalter vereinfacht; Tastenkürzel (Enter, Alt+Enter, + ↑/↓) stehen jetzt unter `/help`. + ## [0.1.0] - 2026-07-25 ### Hinzugefügt diff --git a/internal/llm/openai/request.go b/internal/llm/openai/request.go index 43835b5..2651f20 100644 --- a/internal/llm/openai/request.go +++ b/internal/llm/openai/request.go @@ -12,10 +12,14 @@ const systemSeparator = "--- system ---\n" func buildRequest(req llm.Request, caps llm.Caps) wireRequest { wr := wireRequest{ Model: req.Model, - MaxTokens: req.MaxTokens, Temperature: req.Temp, Stream: true, } + if caps.Reasoning { + wr.MaxCompletionTokens = req.MaxTokens + } else { + wr.MaxTokens = req.MaxTokens + } if caps.UsageInStream { wr.StreamOptions = &streamOptions{IncludeUsage: true} } diff --git a/internal/llm/openai/wire.go b/internal/llm/openai/wire.go index 0f64c35..84582b8 100644 --- a/internal/llm/openai/wire.go +++ b/internal/llm/openai/wire.go @@ -5,13 +5,16 @@ package openai import "encoding/json" type wireRequest struct { - Model string `json:"model"` - Messages []wireMessage `json:"messages"` - Tools []wireToolDef `json:"tools,omitempty"` - MaxTokens int `json:"max_tokens,omitempty"` - Temperature *float64 `json:"temperature,omitempty"` - Stream bool `json:"stream"` - StreamOptions *streamOptions `json:"stream_options,omitempty"` + Model string `json:"model"` + Messages []wireMessage `json:"messages"` + Tools []wireToolDef `json:"tools,omitempty"` + MaxTokens int `json:"max_tokens,omitempty"` + // MaxCompletionTokens ersetzt MaxTokens für Reasoning-Modelle (o1 & Co.): + // die OpenAI-API lehnt "max_tokens" dort ab und verlangt dieses Feld. + MaxCompletionTokens int `json:"max_completion_tokens,omitempty"` + Temperature *float64 `json:"temperature,omitempty"` + Stream bool `json:"stream"` + StreamOptions *streamOptions `json:"stream_options,omitempty"` } type streamOptions struct { diff --git a/internal/ui/tui/commands.go b/internal/ui/tui/commands.go index e8e021c..14b13ee 100644 --- a/internal/ui/tui/commands.go +++ b/internal/ui/tui/commands.go @@ -120,7 +120,11 @@ func (m *Model) helpText() string { } var b strings.Builder - b.WriteString("Kommandos:\n") + b.WriteString("Tasten:\n") + b.WriteString(" Enter Nachricht senden\n") + b.WriteString(" Alt+Enter während einer laufenden Antwort als Follow-up einreihen\n") + b.WriteString(" ↑/↓ Verlauf durchblättern (bei leerem Eingabefeld)\n") + b.WriteString("\nKommandos:\n") for _, c := range helpCommands { fmt.Fprintf(&b, " %-*s %s\n", width, c.cmd, c.desc) } diff --git a/internal/ui/tui/model.go b/internal/ui/tui/model.go index 030c8b2..889e2a4 100644 --- a/internal/ui/tui/model.go +++ b/internal/ui/tui/model.go @@ -60,7 +60,12 @@ type Model struct { tokensUsed int cacheReadTokens int // kumulierte cache_read_tokens, Nachweis für Prompt-Caching (M7) turnActive bool - statusMsg string + + // activeTools sind die gerade laufenden Tool-Calls (ID -> Name), in + // Start-Reihenfolge. Bei parallelen Calls (mehrere Tools gleichzeitig, + // siehe Caps.ParallelToolCalls) zeigt die Statuszeile alle Namen statt + // nur den zuletzt gestarteten zu überschreiben. + activeTools []toolStatus modal *listModal @@ -85,10 +90,29 @@ type Model struct { // defaultInputPlaceholder ist der Standardtext des Eingabefelds. Wird // während einer offenen question-Tool-Rückfrage kurzzeitig überschrieben // (siehe askUser/answerQuestion in question.go). -const defaultInputPlaceholder = "Nachricht eingeben — Enter sendet, Alt+Enter queued als Follow-up, /help für Kommandos" +const defaultInputPlaceholder = "Nachricht eingeben — /help für Kommandos" const askInputPlaceholder = "Antwort eingeben — Enter sendet" +type toolStatus struct { + id string + name string +} + +// addActiveTool/removeActiveTool pflegen m.activeTools für die Statuszeile. +func (m *Model) addActiveTool(id, name string) { + m.activeTools = append(m.activeTools, toolStatus{id: id, name: name}) +} + +func (m *Model) removeActiveTool(id string) { + for i, t := range m.activeTools { + if t.id == id { + m.activeTools = append(m.activeTools[:i], m.activeTools[i+1:]...) + return + } + } +} + type uiEventMsg struct { gen int ev tool.UIEvent @@ -174,7 +198,7 @@ func (m *Model) startSession(store *session.Store) tea.Cmd { m.tokensUsed = usage.InputTokens + usage.OutputTokens m.cacheReadTokens = usage.CacheReadTokens m.turnActive = false - m.statusMsg = "" + m.activeTools = nil m.followupQueue = nil m.liveText.Reset() m.liveThink.Reset() @@ -352,8 +376,12 @@ func (m *Model) renderStatusLine() string { state := "idle" if m.turnActive { state = m.spinner.View() + " running" - if m.statusMsg != "" { - state += ": " + m.statusMsg + if len(m.activeTools) > 0 { + names := make([]string, len(m.activeTools)) + for i, t := range m.activeTools { + names[i] = t.name + } + state += ": " + strings.Join(names, ", ") } } if m.permReq != nil { @@ -409,25 +437,23 @@ func (m *Model) renderViewport() { if i > 0 { b.WriteString("\n\n") } - b.WriteString(renderEntry(e)) + b.WriteString(wrapForViewport(renderEntry(e), entryWrapWidth(e.kind, m.viewport.Width))) } if m.hasLiveThink { if len(m.entries) > 0 { b.WriteString("\n\n") } - b.WriteString(styleThinking.Render(m.liveThink.String())) + b.WriteString(wrapForViewport(styleThinking.Render(m.liveThink.String()), entryWrapWidth(entryThinking, m.viewport.Width))) } if m.hasLiveText { if len(m.entries) > 0 || m.hasLiveThink { b.WriteString("\n\n") } - b.WriteString(styleAssistant.Render(m.liveText.String())) + b.WriteString(wrapForViewport(styleAssistant.Render(m.liveText.String()), entryWrapWidth(entryAssistant, m.viewport.Width))) } - content := wrapForViewport(b.String(), m.viewport.Width) - atBottom := m.viewport.AtBottom() - m.viewport.SetContent(content) + m.viewport.SetContent(b.String()) if atBottom { m.viewport.GotoBottom() } @@ -514,27 +540,40 @@ func (m *Model) handleUIEvent(ev tool.UIEvent) tea.Cmd { case tool.ToolCallStart: m.commitLive() - m.statusMsg = e.Name - m.entries = append(m.entries, entry{kind: entryToolCall, text: renderToolCall(e.Name, e.Input)}) + m.addActiveTool(e.ID, e.Name) + m.entries = append(m.entries, entry{kind: entryToolCall, id: e.ID, text: renderToolCall(e.Name, e.Input)}) m.renderViewport() case tool.ToolStream: // Kompaktes Live-Log für bash & Co.; nicht Zeile für Zeile ins // Transkript, das würde es bei langem Output unlesbar machen. - m.statusMsg = e.ID + "…" + // Statuszeile zeigt bereits den Tool-Namen (seit ToolCallStart). case tool.ToolCallOutput: - m.statusMsg = "" - m.entries = append(m.entries, entry{kind: entryToolResult, text: renderToolResult(e.Name, e.Result)}) + m.removeActiveTool(e.ID) + // Ersetzt den wartenden "→ tool"-Eintrag in-place statt einen + // zweiten Block anzuhängen — halbiert den vertikalen Platzbedarf + // pro Tool-Aufruf und hält die Position im Transkript stabil. + replaced := false + for i := len(m.entries) - 1; i >= 0; i-- { + if m.entries[i].kind == entryToolCall && m.entries[i].id == e.ID { + m.entries[i] = entry{kind: entryToolResult, id: e.ID, text: renderToolResult(e.Name, e.Result)} + replaced = true + break + } + } + if !replaced { + m.entries = append(m.entries, entry{kind: entryToolResult, id: e.ID, text: renderToolResult(e.Name, e.Result)}) + } m.renderViewport() case tool.TurnDone: m.commitLive() + m.renderViewport() m.tokensUsed += e.Usage.InputTokens + e.Usage.OutputTokens m.cacheReadTokens += e.Usage.CacheReadTokens if e.Stop != llm.StopToolUse { m.turnActive = false - m.statusMsg = "" if len(m.followupQueue) > 0 { next := m.followupQueue[0] m.followupQueue = m.followupQueue[1:] @@ -545,7 +584,7 @@ func (m *Model) handleUIEvent(ev tool.UIEvent) tea.Cmd { case tool.ErrorEvent: m.commitLive() m.turnActive = false - m.statusMsg = "" + m.activeTools = nil m.entries = append(m.entries, entry{kind: entryError, text: e.Err.Error()}) m.renderViewport() diff --git a/internal/ui/tui/model_test.go b/internal/ui/tui/model_test.go index 2699e5a..be970f3 100644 --- a/internal/ui/tui/model_test.go +++ b/internal/ui/tui/model_test.go @@ -164,9 +164,13 @@ func TestRunCommand_HelpListsOneCommandPerLineWithDescription(t *testing.T) { t.Fatalf("entries = %+v, want a single entryCommand", m.entries) } text := m.entries[0].text - lines := strings.Split(text, "\n") - if len(lines) != len(helpCommands)+1 { // +1 für die "Kommandos:"-Kopfzeile - t.Fatalf("got %d lines, want %d (one per command + header):\n%s", len(lines), len(helpCommands)+1, text) + _, cmdSection, ok := strings.Cut(text, "Kommandos:\n") + if !ok { + t.Fatalf("help text missing \"Kommandos:\" header:\n%s", text) + } + lines := strings.Split(cmdSection, "\n") + if len(lines) != len(helpCommands) { + t.Fatalf("got %d command lines, want %d (one per command):\n%s", len(lines), len(helpCommands), text) } for _, c := range helpCommands { if !strings.Contains(text, c.cmd) || !strings.Contains(text, c.desc) { diff --git a/internal/ui/tui/render.go b/internal/ui/tui/render.go index 5e01122..d52e694 100644 --- a/internal/ui/tui/render.go +++ b/internal/ui/tui/render.go @@ -43,9 +43,29 @@ const ( type entry struct { kind entryKind + id string // Tool-Call-ID; verknüpft entryToolCall mit dem späteren entryToolResult text string // bereits gestylter, fertiger Text } +// proseMaxWidth begrenzt die Zeilenlänge von Fließtext (Assistant, User, +// Thinking, ...) auf einen lesbaren Wert — bei breiten Terminals wird lange +// Prosa sonst unangenehm zu lesen. Tool-Output/Diffs bekommen die volle +// Viewport-Breite, da Tabellen/Diffs von der zusätzlichen Breite profitieren. +const proseMaxWidth = 100 + +// entryWrapWidth liefert die Wrap-Breite für einen Entry-Typ. +func entryWrapWidth(k entryKind, viewportWidth int) int { + if viewportWidth <= 0 || viewportWidth > proseMaxWidth { + switch k { + case entryAssistant, entryUser, entryQueued, entryThinking, entrySystem, entryCommand, entryError: + if viewportWidth > 0 { + return proseMaxWidth + } + } + } + return viewportWidth +} + var markdownRenderer, _ = glamour.NewTermRenderer( glamour.WithAutoStyle(), glamour.WithWordWrap(0), // kein Wrap hier — Model.renderViewport bricht den gesamten Inhalt einmalig auf die aktuelle Breite um diff --git a/test.py b/test.py new file mode 100644 index 0000000..702fe25 --- /dev/null +++ b/test.py @@ -0,0 +1,8 @@ +#!/usr/bin/env python3 + +def main(): + print("Hello, World!") + + +if __name__ == "__main__": + main()