Update 0.2.0

- siehe Changelog
This commit is contained in:
Tom 2026-07-27 20:06:22 +02:00
parent a97013d876
commit bd7e13385d
8 changed files with 137 additions and 30 deletions

View file

@ -7,6 +7,31 @@ Dieses Projekt ist privat und folgt keinem formalen Versionsschema
([SemVer](https://semver.org/lang/de/)) — Versionsnummern hier dienen nur ([SemVer](https://semver.org/lang/de/)) — Versionsnummern hier dienen nur
der zeitlichen Einordnung. 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 ## [0.1.0] - 2026-07-25
### Hinzugefügt ### Hinzugefügt

View file

@ -12,10 +12,14 @@ const systemSeparator = "--- system ---\n"
func buildRequest(req llm.Request, caps llm.Caps) wireRequest { func buildRequest(req llm.Request, caps llm.Caps) wireRequest {
wr := wireRequest{ wr := wireRequest{
Model: req.Model, Model: req.Model,
MaxTokens: req.MaxTokens,
Temperature: req.Temp, Temperature: req.Temp,
Stream: true, Stream: true,
} }
if caps.Reasoning {
wr.MaxCompletionTokens = req.MaxTokens
} else {
wr.MaxTokens = req.MaxTokens
}
if caps.UsageInStream { if caps.UsageInStream {
wr.StreamOptions = &streamOptions{IncludeUsage: true} wr.StreamOptions = &streamOptions{IncludeUsage: true}
} }

View file

@ -5,13 +5,16 @@ package openai
import "encoding/json" import "encoding/json"
type wireRequest struct { type wireRequest struct {
Model string `json:"model"` Model string `json:"model"`
Messages []wireMessage `json:"messages"` Messages []wireMessage `json:"messages"`
Tools []wireToolDef `json:"tools,omitempty"` Tools []wireToolDef `json:"tools,omitempty"`
MaxTokens int `json:"max_tokens,omitempty"` MaxTokens int `json:"max_tokens,omitempty"`
Temperature *float64 `json:"temperature,omitempty"` // MaxCompletionTokens ersetzt MaxTokens für Reasoning-Modelle (o1 & Co.):
Stream bool `json:"stream"` // die OpenAI-API lehnt "max_tokens" dort ab und verlangt dieses Feld.
StreamOptions *streamOptions `json:"stream_options,omitempty"` 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 { type streamOptions struct {

View file

@ -120,7 +120,11 @@ func (m *Model) helpText() string {
} }
var b strings.Builder 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 { for _, c := range helpCommands {
fmt.Fprintf(&b, " %-*s %s\n", width, c.cmd, c.desc) fmt.Fprintf(&b, " %-*s %s\n", width, c.cmd, c.desc)
} }

View file

@ -60,7 +60,12 @@ type Model struct {
tokensUsed int tokensUsed int
cacheReadTokens int // kumulierte cache_read_tokens, Nachweis für Prompt-Caching (M7) cacheReadTokens int // kumulierte cache_read_tokens, Nachweis für Prompt-Caching (M7)
turnActive bool 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 modal *listModal
@ -85,10 +90,29 @@ type Model struct {
// defaultInputPlaceholder ist der Standardtext des Eingabefelds. Wird // defaultInputPlaceholder ist der Standardtext des Eingabefelds. Wird
// während einer offenen question-Tool-Rückfrage kurzzeitig überschrieben // während einer offenen question-Tool-Rückfrage kurzzeitig überschrieben
// (siehe askUser/answerQuestion in question.go). // (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" 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 { type uiEventMsg struct {
gen int gen int
ev tool.UIEvent ev tool.UIEvent
@ -174,7 +198,7 @@ func (m *Model) startSession(store *session.Store) tea.Cmd {
m.tokensUsed = usage.InputTokens + usage.OutputTokens m.tokensUsed = usage.InputTokens + usage.OutputTokens
m.cacheReadTokens = usage.CacheReadTokens m.cacheReadTokens = usage.CacheReadTokens
m.turnActive = false m.turnActive = false
m.statusMsg = "" m.activeTools = nil
m.followupQueue = nil m.followupQueue = nil
m.liveText.Reset() m.liveText.Reset()
m.liveThink.Reset() m.liveThink.Reset()
@ -352,8 +376,12 @@ func (m *Model) renderStatusLine() string {
state := "idle" state := "idle"
if m.turnActive { if m.turnActive {
state = m.spinner.View() + " running" state = m.spinner.View() + " running"
if m.statusMsg != "" { if len(m.activeTools) > 0 {
state += ": " + m.statusMsg names := make([]string, len(m.activeTools))
for i, t := range m.activeTools {
names[i] = t.name
}
state += ": " + strings.Join(names, ", ")
} }
} }
if m.permReq != nil { if m.permReq != nil {
@ -409,25 +437,23 @@ func (m *Model) renderViewport() {
if i > 0 { if i > 0 {
b.WriteString("\n\n") b.WriteString("\n\n")
} }
b.WriteString(renderEntry(e)) b.WriteString(wrapForViewport(renderEntry(e), entryWrapWidth(e.kind, m.viewport.Width)))
} }
if m.hasLiveThink { if m.hasLiveThink {
if len(m.entries) > 0 { if len(m.entries) > 0 {
b.WriteString("\n\n") 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 m.hasLiveText {
if len(m.entries) > 0 || m.hasLiveThink { if len(m.entries) > 0 || m.hasLiveThink {
b.WriteString("\n\n") 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() atBottom := m.viewport.AtBottom()
m.viewport.SetContent(content) m.viewport.SetContent(b.String())
if atBottom { if atBottom {
m.viewport.GotoBottom() m.viewport.GotoBottom()
} }
@ -514,27 +540,40 @@ func (m *Model) handleUIEvent(ev tool.UIEvent) tea.Cmd {
case tool.ToolCallStart: case tool.ToolCallStart:
m.commitLive() m.commitLive()
m.statusMsg = e.Name m.addActiveTool(e.ID, e.Name)
m.entries = append(m.entries, entry{kind: entryToolCall, text: renderToolCall(e.Name, e.Input)}) m.entries = append(m.entries, entry{kind: entryToolCall, id: e.ID, text: renderToolCall(e.Name, e.Input)})
m.renderViewport() m.renderViewport()
case tool.ToolStream: case tool.ToolStream:
// Kompaktes Live-Log für bash & Co.; nicht Zeile für Zeile ins // Kompaktes Live-Log für bash & Co.; nicht Zeile für Zeile ins
// Transkript, das würde es bei langem Output unlesbar machen. // 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: case tool.ToolCallOutput:
m.statusMsg = "" m.removeActiveTool(e.ID)
m.entries = append(m.entries, entry{kind: entryToolResult, text: renderToolResult(e.Name, e.Result)}) // 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() m.renderViewport()
case tool.TurnDone: case tool.TurnDone:
m.commitLive() m.commitLive()
m.renderViewport()
m.tokensUsed += e.Usage.InputTokens + e.Usage.OutputTokens m.tokensUsed += e.Usage.InputTokens + e.Usage.OutputTokens
m.cacheReadTokens += e.Usage.CacheReadTokens m.cacheReadTokens += e.Usage.CacheReadTokens
if e.Stop != llm.StopToolUse { if e.Stop != llm.StopToolUse {
m.turnActive = false m.turnActive = false
m.statusMsg = ""
if len(m.followupQueue) > 0 { if len(m.followupQueue) > 0 {
next := m.followupQueue[0] next := m.followupQueue[0]
m.followupQueue = m.followupQueue[1:] m.followupQueue = m.followupQueue[1:]
@ -545,7 +584,7 @@ func (m *Model) handleUIEvent(ev tool.UIEvent) tea.Cmd {
case tool.ErrorEvent: case tool.ErrorEvent:
m.commitLive() m.commitLive()
m.turnActive = false m.turnActive = false
m.statusMsg = "" m.activeTools = nil
m.entries = append(m.entries, entry{kind: entryError, text: e.Err.Error()}) m.entries = append(m.entries, entry{kind: entryError, text: e.Err.Error()})
m.renderViewport() m.renderViewport()

View file

@ -164,9 +164,13 @@ func TestRunCommand_HelpListsOneCommandPerLineWithDescription(t *testing.T) {
t.Fatalf("entries = %+v, want a single entryCommand", m.entries) t.Fatalf("entries = %+v, want a single entryCommand", m.entries)
} }
text := m.entries[0].text text := m.entries[0].text
lines := strings.Split(text, "\n") _, cmdSection, ok := strings.Cut(text, "Kommandos:\n")
if len(lines) != len(helpCommands)+1 { // +1 für die "Kommandos:"-Kopfzeile if !ok {
t.Fatalf("got %d lines, want %d (one per command + header):\n%s", len(lines), len(helpCommands)+1, text) 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 { for _, c := range helpCommands {
if !strings.Contains(text, c.cmd) || !strings.Contains(text, c.desc) { if !strings.Contains(text, c.cmd) || !strings.Contains(text, c.desc) {

View file

@ -43,9 +43,29 @@ const (
type entry struct { type entry struct {
kind entryKind kind entryKind
id string // Tool-Call-ID; verknüpft entryToolCall mit dem späteren entryToolResult
text string // bereits gestylter, fertiger Text 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( var markdownRenderer, _ = glamour.NewTermRenderer(
glamour.WithAutoStyle(), glamour.WithAutoStyle(),
glamour.WithWordWrap(0), // kein Wrap hier — Model.renderViewport bricht den gesamten Inhalt einmalig auf die aktuelle Breite um glamour.WithWordWrap(0), // kein Wrap hier — Model.renderViewport bricht den gesamten Inhalt einmalig auf die aktuelle Breite um

8
test.py Normal file
View file

@ -0,0 +1,8 @@
#!/usr/bin/env python3
def main():
print("Hello, World!")
if __name__ == "__main__":
main()