package app import ( "fmt" "os" "strings" "github.com/charmbracelet/lipgloss" "github.com/charmbracelet/x/ansi" ) const ( statusH = 1 footerH = 1 inputH = 4 // rounded box: 2 borders + 2 content lines // staleEventThresholdMs: past this gap since the last event, an *active* session's // last-event clock turns warn-colored — the "thinking vs hung" tell (§2). staleEventThresholdMs = 30_000 // narrowWidth: below this the side-by-side panels become unreadable, so the main // area collapses (single column / vertical stack) and the chrome goes compact. narrowWidth = 96 ) // narrow reports whether the terminal is too slim for the full side-by-side layout // and full-width chrome. func (m Model) narrow() bool { return m.width < narrowWidth } // View renders the whole frame purely from the model (immediate-mode). func (m Model) View() string { if m.quitting { return "" } if m.width < 20 || m.height < 8 { return m.theme.Screen.Render("correx — terminal too small") } bottom := m.renderInput() bottomH := inputH if m.displayState() == StateApproval { bottomH = m.approvalBandHeight() bottom = m.renderApprovalBand(bottomH) } mainH := m.height - statusH - footerH - bottomH if mainH < 3 { mainH = 3 } base := lipgloss.JoinVertical(lipgloss.Left, m.renderStatus(), m.renderMain(mainH), bottom, m.renderFooter(), ) // Stash the base so center() can composite a modal over a dimmed copy of it. m.lastBase = base if m.displayState() == StateClarification { return m.center(m.clarificationModal()) } if m.displayState() == StateWorkflowPropose { return m.center(m.proposeModal()) } if m.overlay != OverlayNone { return m.renderOverlay(base) } return base } // --- top status bar --- func (m Model) renderStatus() string { t := m.theme bg := t.P.BgPanel span := func(s string, fg lipgloss.Color) string { return lipgloss.NewStyle().Foreground(fg).Background(bg).Render(s) } nrw := m.narrow() sepStr := " · " if nrw { sepStr = " · " } sep := span(sepStr, t.P.Faint) parts := []string{span("correx", t.P.Dim)} if m.connected { parts = append(parts, span("●", t.P.OK)+span(" connected", t.P.Dim)) } else if m.reconnecting { parts = append(parts, span("◌", t.P.Warn)+span(" reconnecting", t.P.Warn)) } else { parts = append(parts, span("○", t.P.Bad)+span(" disconnected", t.P.Bad)) } if s := m.session(m.selectedID); s != nil && m.displayState() != StateIdle { parts = append(parts, span(s.Name, t.P.FgStrong)) if s.CurrentStage != "" { parts = append(parts, span("⟐ "+s.CurrentStage, t.P.Accent2)) } if s.Status != "" { parts = append(parts, lipgloss.NewStyle().Foreground(statusColor(t, s.Status)).Background(bg).Render(statusLabel(s.Status))) } if s.WorkspaceRoot != "" && !nrw { parts = append(parts, span("⌂ "+shortPath(s.WorkspaceRoot), t.P.Faint)) } } model := m.currentModel if model == "" { model = "no model" } if nrw { parts = append(parts, span(model, t.P.Fg)) } else { loc := "local" if m.providerType == "REMOTE" { loc = "remote" } parts = append(parts, span(model, t.P.Fg)+span(" ("+loc+")", t.P.Faint)) } left := strings.Join(parts, sep) var right []string // Last-event clock (§2): always visible in-session, so a silent agent is never // mistaken for a healthy one. Updates every frame tick; turns warn-colored when an // active session has gone quiet past the stale threshold. if s := m.session(m.selectedID); s != nil && m.displayState() != StateIdle { gap := nowMillis() - s.LastEventAt clockFg := t.P.Faint if s.Active && gap > staleEventThresholdMs { clockFg = t.P.Warn } label := "last event " + agoLabel(gap) if nrw { // compact: "◷ 3s" instead of "last event 3s ago" label = "◷ " + strings.TrimSuffix(agoLabel(gap), " ago") } right = append(right, span(label, clockFg)) } if g := m.gaugeText(); g != "" && !nrw { right = append(right, span(g, t.P.Faint)) } if s := m.session(m.selectedID); s != nil && s.Active { spin := lipgloss.NewStyle().Foreground(t.P.Accent).Background(bg).Render(m.spinner()) if nrw { right = append(right, spin) } else { right = append(right, spin+span(" active", t.P.Accent2)) } } if m.bgUpdates > 0 { label := itoa(m.bgUpdates) + " elsewhere" if nrw { label = itoa(m.bgUpdates) + "↗" } right = append(right, span(label, t.P.Warn)) } if len(right) == 0 { return m.fill(left, m.width, bg) } return m.justify(left, strings.Join(right, sep), m.width, bg) } // gaugeText renders the live VRAM/GPU/RAM gauge, omitting any unavailable field. func (m Model) gaugeText() string { var parts []string if m.gpuUsedMB != nil && m.gpuTotalMB != nil { parts = append(parts, "VRAM "+itoa(int(*m.gpuUsedMB))+"/"+itoa(int(*m.gpuTotalMB))+"M") } if m.gpuUtil != nil { parts = append(parts, "GPU "+itoa(*m.gpuUtil)+"%") } if m.sysRamUsedMB != nil && m.sysRamTotalMB != nil { parts = append(parts, "RAM "+itoa(int(*m.sysRamUsedMB))+"/"+itoa(int(*m.sysRamTotalMB))+"M") } if m.ramMB != nil { parts = append(parts, "proc "+itoa(int(*m.ramMB))+"M") } return strings.Join(parts, " ") } // agoLabel renders a millisecond age as a compact "Ns ago" / "Nm ago" / "Nh ago" / // "Nd ago". Drives the last-event clock (§2) — the live signal that distinguishes a // thinking agent from a hung one. func agoLabel(deltaMs int64) string { if deltaMs < 0 { deltaMs = 0 } s := deltaMs / 1000 switch { case s < 60: return itoa(int(s)) + "s ago" case s < 3600: return itoa(int(s/60)) + "m ago" case s < 86400: return itoa(int(s/3600)) + "h ago" default: return itoa(int(s/86400)) + "d ago" } } var spinnerFrames = []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"} func (m Model) spinner() string { return spinnerFrames[m.frame%len(spinnerFrames)] } // caretVisible blinks the insert caret roughly twice a second. func (m Model) caretVisible() bool { return (m.frame/4)%2 == 0 } // --- bottom footer hints --- func (m Model) renderFooter() string { t := m.theme bg := t.P.BgDeep hint := func(key, label string) string { k := lipgloss.NewStyle().Foreground(t.P.Accent).Background(bg).Bold(true).Render(key) l := lipgloss.NewStyle().Foreground(t.P.Faint).Background(bg).Render(" " + label) return k + l } gap := lipgloss.NewStyle().Background(bg).Render(" ") nrw := m.narrow() var hints []string switch { case m.editMode == ModeInsert: hints = []string{hint("enter", "send"), hint("esc", "normal"), hint("↑↓", "history")} // The `/` command menu only triggers on an empty chat line (mid-line `/` is literal). if m.inputMode == ModeRouter && m.inputBuffer == "" { hints = append(hints, hint("/", "cmds")) } case m.displayState() == StateApproval: // Approval actions live in the band itself; the footer offers navigation only. hints = []string{hint("l", "back"), hint("e", "events"), hint("q", "quit")} case m.displayState() == StateClarification: // The question form owns its keys; the footer mirrors the essentials. hints = []string{hint("←→", "option"), hint("↑↓", "question"), hint("spc", "select"), hint("enter", "submit"), hint("esc", "later")} case m.displayState() == StateWorkflowPropose: // The picker owns its keys; the footer mirrors the essentials. hints = []string{hint("↑↓", "choose"), hint("enter", "launch"), hint("e", "custom"), hint("esc", "later")} case m.displayState() == StateIdle: if nrw { hints = []string{hint("i", "name"), hint("/", "filter"), hint("w", "wf"), hint("R", "resume"), hint("p", "cmds"), hint("q", "quit")} } else { hints = []string{hint("i", "name"), hint("/", "filter"), hint("enter", "open"), hint("jk", "move"), hint("w", "workflows"), hint("R", "resume"), hint("p", "cmds"), hint("q", "quit")} } default: // StateInSession if nrw { hints = []string{hint("i", "msg"), hint("e", "events"), hint("S", "stats"), hint("l", "back"), hint("p", "cmds"), hint("q", "quit")} } else { hints = []string{hint("i", "message"), hint("e", "events"), hint("t", "tools"), hint("S", "stats"), hint("m", "model"), hint("s", "mode"), hint("l", "back"), hint("p", "cmds"), hint("q", "quit")} } if s := m.session(m.selectedID); s != nil && s.Pending != nil { hints = append(hints, hint("a", "approval (pending)")) } } left := strings.Join(hints, gap) // The "Soft · blue" badge is decoration — drop it when slim so the hints get the room. if nrw { return m.fill(left, m.width, bg) } right := lipgloss.NewStyle().Foreground(t.P.Dim).Background(bg).Render("Soft") + lipgloss.NewStyle().Foreground(t.P.Faint).Background(bg).Render(" · ") + lipgloss.NewStyle().Foreground(t.P.Accent).Background(bg).Render("blue") return m.justify(left, right, m.width, bg) } // --- main split --- func (m Model) renderMain(h int) string { if m.narrow() { return m.renderMainNarrow(h) } leftW := m.width * 63 / 100 rightW := m.width - leftW var leftTitle, rightTitle string var leftBody, rightBody []string leftActive, rightActive := false, false switch m.displayState() { case StateIdle: leftTitle = "sessions" if m.wfVisible { leftTitle = "workflows" leftBody = m.workflowRows(leftW - 4) } else { leftBody = m.sessionRows(leftW - 4) } leftActive = true rightTitle = "welcome" rightBody = m.welcomeRows(rightW - 4) case StateInSession, StateApproval: leftTitle = "output" leftBody = m.routerRows(leftW-4, h-2) leftActive = true rightTitle = "events" rightBody = m.eventRows(rightW-4, h) } left := m.theme.box(leftTitle, leftBody, leftW, h, leftActive) right := m.theme.box(rightTitle, rightBody, rightW, h, rightActive) return lipgloss.JoinHorizontal(lipgloss.Top, left, right) } // renderMainNarrow gives the main area a single full-width column when the terminal // is too slim for side-by-side panels: the session list when idle, and the output // transcript stacked over the live event strip when in-session (so both still fit // — the strip is too valuable to drop, the constraint is width not height). func (m Model) renderMainNarrow(h int) string { w := m.width if m.displayState() == StateIdle { title := "sessions" body := m.sessionRows(w - 4) if m.wfVisible { title, body = "workflows", m.workflowRows(w-4) } return m.theme.box(title, body, w, h, true) } // in-session / approval: stack output over events. outH := h * 55 / 100 if outH < 3 { outH = 3 } evH := h - outH if evH < 3 { evH = 3 outH = h - evH } out := m.theme.box("output", m.routerRows(w-4, outH-2), w, outH, true) ev := m.theme.box("events", m.eventRows(w-4, evH), w, evH, false) return lipgloss.JoinVertical(lipgloss.Left, out, ev) } // --- input bar --- // flattenForDisplay collapses control characters (newlines from a multi-line paste, tabs) // to a single visible line so the fixed-height input box can't overflow and smear stale cells // (F-006). The underlying inputBuffer keeps its real characters for submission. func flattenForDisplay(s string) string { repl := strings.NewReplacer("\r\n", "↵ ", "\r", "↵ ", "\n", "↵ ", "\t", " ") return repl.Replace(s) } func (m Model) renderInput() string { t := m.theme placeholder := "Ask anything…" switch { case m.inputMode == ModeIntent: placeholder = "Describe the task for " + m.wfPendingName + " (Enter to start, empty = none)…" case m.inputMode == ModeFilter: placeholder = "Filter sessions…" case m.displayState() == StateIdle: placeholder = "Session name…" } insert := m.editMode == ModeInsert caret := t.span(" ", t.P.Bg) if insert && m.caretVisible() { caret = lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.Bg).Render("▏") } var line string switch { case m.inputBuffer == "" && !insert: line = t.span(placeholder, t.P.Faint) case m.inputBuffer == "": line = caret + t.span(placeholder, t.P.Faint) default: line = t.span(flattenForDisplay(m.inputBuffer), t.P.FgStrong) + caret } // status sub-line: · · name := "(no session)" if s := m.session(m.selectedID); s != nil { name = s.Name } mode := "chat" if m.chatMode == ChatModeSteering { mode = "steering" } if m.inputMode == ModeFilter { mode = "filter" } if m.inputMode == ModeIntent { mode = "task" } sub := lipgloss.NewStyle().Foreground(t.P.Accent2).Background(t.P.Bg).Render(name) if m.selectedID != "" { sub += t.span(" · ", t.P.Faint) + t.span(m.selectedID, t.P.Faint) } sub += t.span(" · ", t.P.Faint) + t.span(mode, t.P.Dim) body := []string{line, sub} return t.box("", body, m.width, inputH, insert) } // --- body row builders --- func (m Model) sessionRows(w int) []string { t := m.theme list := m.filteredSessions() if len(list) == 0 { return []string{t.span("no sessions yet", t.P.Faint), "", t.span("type a name below to begin", t.P.Faint)} } rows := make([]string, 0, len(list)) for _, s := range list { sel := s.ID == m.selectedID && !m.wfVisible short := s.ID if len(short) > 6 { short = short[:6] } nameFg := t.P.Fg bar := t.span(" ", t.P.Bg) if sel { bar = lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.Bg).Render("▌ ") nameFg = t.P.FgStrong } idCell := t.span("["+short+"] ", t.P.Faint) statusCell := lipgloss.NewStyle().Foreground(statusColor(t, s.Status)).Background(t.P.Bg).Bold(true). Render(padRaw(statusLabel(s.Status), 9)) nameCell := lipgloss.NewStyle().Foreground(nameFg).Background(t.P.Bg).Render(" " + s.Name) stage := "" if s.CurrentStage != "" { stage = t.span(" ["+s.CurrentStage+"]", t.P.Dim) } rows = append(rows, bar+idCell+statusCell+nameCell+stage) } return rows } func (m Model) workflowRows(w int) []string { t := m.theme if len(m.workflows) == 0 { return []string{t.span("no workflows advertised", t.P.Faint)} } rows := make([]string, 0, len(m.workflows)) for i, wf := range m.workflows { marker := t.span(" ", t.P.Bg) fg := t.P.Fg if i == m.wfIndex { marker = lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.Bg).Render("▸ ") fg = t.P.FgStrong } rows = append(rows, marker+lipgloss.NewStyle().Foreground(fg).Background(t.P.Bg).Render(wf.ID)+t.span(" "+wf.Description, t.P.Faint)) } return rows } func (m Model) welcomeRows(w int) []string { t := m.theme title := lipgloss.NewStyle().Foreground(t.P.FgStrong).Background(t.P.Bg).Bold(true).Render("Corre") + lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.Bg).Bold(true).Render("x") + lipgloss.NewStyle().Foreground(t.P.FgStrong).Background(t.P.Bg).Bold(true).Render(" Agent Harness") var status string if m.connected { status = lipgloss.NewStyle().Foreground(t.P.OK).Background(t.P.Bg).Render("Connected") + t.span(" · ", t.P.Faint) + t.span(plural(len(m.sessions), "session"), t.P.Dim) } else { status = lipgloss.NewStyle().Foreground(t.P.Bad).Background(t.P.Bg).Render("Disconnected") } rows := []string{title, status, "", t.span("Select a session from the left", t.P.Dim), t.span("or type a name to start a new one.", t.P.Dim), ""} if n := len(m.sessions); n > 0 { rows = append(rows, t.span("recent:", t.P.Faint)) start := 0 if n > 5 { start = n - 5 } for _, s := range m.sessions[start:] { mark := statusGlyph(t, s.Status) rows = append(rows, t.span(" "+s.Name+" ", t.P.Fg)+t.span(formatTime(s.LastEventAt), t.P.Faint)+" "+mark) } } return rows } func (m Model) routerRows(w, h int) []string { t := m.theme msgs := m.routerMessages[m.selectedID] if len(msgs) == 0 { return []string{t.span("awaiting router response…", t.P.Faint)} } var rows []string for _, e := range msgs { switch e.Role { case "user": tag := lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.Bg).Bold(true).Render("›") rows = append(rows, tag+" "+t.span(e.Content, t.P.FgStrong)) case "router": // The router turn is model output: render it as markdown (bold, // lists, headings, code) wrapped to the panel width. glamour applies // its own inline foreground colors; we keep the opaque panel by // fixing each line's background. On any failure renderMarkdown hands // back the plain content, which still flows through this path fine. rendered := renderMarkdown(e.Content, w) for _, ln := range strings.Split(rendered, "\n") { rows = append(rows, lipgloss.NewStyle().Background(t.P.Bg).Render(ln)) } if s := metricsSuffix(e.Metrics); s != "" { rows = append(rows, t.span(s, t.P.Faint)) } case "tool": rows = append(rows, t.span("· tool output ("+itoaLen(e.Content)+" chars) — ^x to view", t.P.Dim)) case "narration_llm": diamond := lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.Bg).Bold(true).Render("◆") lines := wrap(e.Content, w-2) for i, ln := range lines { if i == 0 { rows = append(rows, diamond+" "+t.span(ln, t.P.FgStrong)) } else { rows = append(rows, " "+t.span(ln, t.P.FgStrong)) } } if s := metricsSuffix(e.Metrics); s != "" { rows = append(rows, t.span(s, t.P.Faint)) } case "narration", "stage": rows = append(rows, t.span(e.Content, t.P.Dim)) } } // keep last h rows if len(rows) > h { rows = rows[len(rows)-h:] } return rows } func (m Model) eventRows(w, h int) []string { t := m.theme header := lipgloss.NewStyle().Foreground(t.P.Accent).Background(t.P.Bg).Bold(true).Render("event stream") + t.span(" ", t.P.Bg) if m.connected { header += lipgloss.NewStyle().Foreground(t.P.OK).Background(t.P.Bg).Render("● live") } else { header += t.span("○ idle", t.P.Faint) } s := m.session(m.selectedID) if s == nil || len(s.Events) == 0 { return []string{header, "", t.span("no events yet", t.P.Faint)} } evRows := make([]string, 0, len(s.Events)) for _, e := range s.Events { cat := inferCategory(e.Type) catCell := lipgloss.NewStyle().Foreground(t.categoryColor(cat)).Background(t.P.BgPanel). Render(" " + padRaw(cat, 9) + " ") evRows = append(evRows, t.span(e.Time+" ", t.P.Faint)+catCell+" "+ t.span(padRaw(e.Type, 18), t.P.Fg)+t.span(e.Detail, t.P.Dim)) } // Show the latest events that fit under the 2-line header (box inner = h-2). if avail := h - 4; avail >= 1 && len(evRows) > avail { evRows = evRows[len(evRows)-avail:] } return append([]string{header, ""}, evRows...) } // --- width helpers --- // shortPath abbreviates the user's home dir to ~ for a compact cwd in the status bar. func shortPath(p string) string { if home := os.Getenv("HOME"); home != "" && strings.HasPrefix(p, home) { return "~" + p[len(home):] } return p } // fill left-justifies a styled line and pads with bg to width w. func (m Model) fill(s string, w int, bg lipgloss.Color) string { return " " + padTo(s, w-1, bg) } // justify places left and right segments on a bg-filled line of width w, truncating // so the line never overflows w. The left segment is shrunk first (the right holds // the high-signal status — connection clock / active spinner); the right is clamped // only as a last resort. ANSI-aware so truncation can't slice through escape codes. func (m Model) justify(left, right string, w int, bg lipgloss.Color) string { avail := w - 2 // leading + trailing space lw := lipgloss.Width(left) rw := lipgloss.Width(right) if lw+rw+1 > avail { maxLeft := avail - rw - 1 if maxLeft < 0 { maxLeft = 0 } left = ansi.Truncate(left, maxLeft, "…") lw = lipgloss.Width(left) if lw+rw+1 > avail { right = ansi.Truncate(right, max(0, avail-lw-1), "…") rw = lipgloss.Width(right) } } mid := avail - lw - rw if mid < 1 { mid = 1 } pad := lipgloss.NewStyle().Background(bg).Render(strings.Repeat(" ", mid)) return " " + left + pad + right + lipgloss.NewStyle().Background(bg).Render(" ") } // padTo pads (or truncates) a possibly-styled string to visible width w, filling // with the given background so the panel stays opaque. func padTo(s string, w int, bg lipgloss.Color) string { vw := lipgloss.Width(s) if vw == w { return s } if vw > w { return ansi.Truncate(s, w, "") } return s + lipgloss.NewStyle().Background(bg).Render(strings.Repeat(" ", w-vw)) } // padRaw pads a plain (unstyled) string to width w with spaces, truncating with // an ellipsis only when it genuinely overflows. func padRaw(s string, w int) string { n := len([]rune(s)) if n == w { return s } if n > w { if w <= 1 { return string([]rune(s)[:w]) } return string([]rune(s)[:w-1]) + "…" } return s + strings.Repeat(" ", w-n) } func statusColor(t Theme, status string) lipgloss.Color { u := strings.ToUpper(status) switch { case strings.Contains(u, "FAIL"): return t.P.Bad case strings.Contains(u, "COMPLETE"): return t.P.Dim case strings.Contains(u, "PAUSE"), strings.Contains(u, "STARTING"): return t.P.Warn case strings.Contains(u, "CHAT"), strings.Contains(u, "ACTIVE"): return t.P.Accent default: return t.P.Dim } } func statusLabel(status string) string { u := strings.ToUpper(status) if i := strings.Index(u, " "); i > 0 { return u[:i] } return u } func statusGlyph(t Theme, status string) string { u := strings.ToUpper(status) switch { case strings.Contains(u, "FAIL"): return lipgloss.NewStyle().Foreground(t.P.Bad).Background(t.P.Bg).Render("✗") case strings.Contains(u, "COMPLETE"): return lipgloss.NewStyle().Foreground(t.P.OK).Background(t.P.Bg).Render("✓") default: return t.span("·", t.P.Faint) } } // span renders text with foreground fg over the panel background. func (t Theme) span(s string, fg lipgloss.Color) string { return lipgloss.NewStyle().Foreground(fg).Background(t.P.Bg).Render(s) } // box draws a rounded, opaque panel with the title embedded in the top border. func (t Theme) box(title string, body []string, w, h int, active bool) string { if w < 4 { w = 4 } if h < 2 { h = 2 } inner := w - 2 textW := inner - 2 bc := t.P.Border if active { bc = t.P.BorderHi } bs := lipgloss.NewStyle().Foreground(bc).Background(t.P.Bg) cell := lipgloss.NewStyle().Background(t.P.Bg) var top string if title == "" { top = bs.Render("╭" + strings.Repeat("─", inner) + "╮") } else { ts := t.PanelTitle if active { ts = t.TitleHi } label := strings.ToUpper(title) fill := inner - (2 + lipgloss.Width(label) + 1) if fill < 0 { fill = 0 } top = bs.Render("╭─ ") + ts.Render(label) + bs.Render(" "+strings.Repeat("─", fill)) + bs.Render("╮") } lines := make([]string, 0, h) lines = append(lines, top) for i := 0; i < h-2; i++ { content := "" if i < len(body) { content = body[i] } padded := padTo(content, textW, t.P.Bg) lines = append(lines, bs.Render("│")+cell.Render(" ")+padded+cell.Render(" ")+bs.Render("│")) } lines = append(lines, bs.Render("╰"+strings.Repeat("─", inner)+"╯")) return strings.Join(lines, "\n") } func plural(n int, word string) string { s := itoa(n) + " " + word if n != 1 { s += "s" } return s } func itoa(n int) string { if n == 0 { return "0" } neg := n < 0 if neg { n = -n } var b [20]byte i := len(b) for n > 0 { i-- b[i] = byte('0' + n%10) n /= 10 } if neg { i-- b[i] = '-' } return string(b[i:]) } func itoaLen(s string) string { return itoa(len(s)) } // metricsSuffix formats the faint latency+token annotation for a ROUTER turn. func metricsSuffix(mtr *TurnMetrics) string { if mtr == nil { return "" } return fmt.Sprintf(" %.1fs · %d tokens", float64(mtr.LatencyMs)/1000.0, mtr.TotalTokens) } // wrap splits s into lines no wider than w (rune-based, whitespace-greedy). func wrap(s string, w int) []string { if w < 1 { w = 1 } words := strings.Fields(s) if len(words) == 0 { return []string{""} } var lines []string cur := "" for _, word := range words { if cur == "" { cur = word } else if len(cur)+1+len(word) <= w { cur += " " + word } else { lines = append(lines, cur) cur = word } } if cur != "" { lines = append(lines, cur) } return lines }