Update 0.2.0
- siehe Changelog
This commit is contained in:
parent
a97013d876
commit
bd7e13385d
8 changed files with 137 additions and 30 deletions
25
CHANGELOG.md
25
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
|
([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
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,9 @@ type wireRequest struct {
|
||||||
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"`
|
||||||
|
// 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"`
|
Temperature *float64 `json:"temperature,omitempty"`
|
||||||
Stream bool `json:"stream"`
|
Stream bool `json:"stream"`
|
||||||
StreamOptions *streamOptions `json:"stream_options,omitempty"`
|
StreamOptions *streamOptions `json:"stream_options,omitempty"`
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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
8
test.py
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
|
def main():
|
||||||
|
print("Hello, World!")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Loading…
Add table
Reference in a new issue