Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,7 @@ export OPENAI_MODEL="model-name"
* **Developer Ergonomics:**
* `/compose`: Pop open your preferred `$EDITOR` (Neovim, Vim, Helix, VS Code) to draft complex, multi-line instructions.
* `/rewind`: Visual history scrubber to roll back turns and branch conversational states.
* `/infobar` / `/timestamps`: Toggle the info footer (version, model, context, subagents, skills, uptime) and `[HH:MM:SS]` message timestamps; both persist to `config.json`.
* `late --prompt "..."`: Start a session with a pre-given prompt, useful for running it from other scripts.
* **Auditable Subagent History:** Full subagent conversation transcripts and metadata are persisted to disk for total transparency and debugging—without poisoning the orchestrator's active context window. Opt-in persistence protects your disk space while ensuring you can debug overnight autopilot runs.
* **Exact-Match Diffs & Autonomous Healing:** Strict `search`/`replace` editing with automatic self-healing on mismatch. Edits fail loud; files are never silently corrupted.
Expand Down
31 changes: 31 additions & 0 deletions cmd/late/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import (
"late/internal/pathutil"
"late/internal/plugin"
"late/internal/session"
"late/internal/skill"
"late/internal/tool"
"late/internal/tui"

Expand Down Expand Up @@ -665,6 +666,12 @@ func main() {
model.ShowCWD = *showCWDReq
model.LazyHistory = true

// SkillsInfo: estimated size of the skill surface for the TUI info bar.
// executor.RegisterTools builds the activate_skill map from the same
// directories; re-discovering here (read-only) keeps the executor API
// untouched while giving the TUI the same view of the skills on disk.
model.SkillsInfo = skillsInfoForTUI()

pOpts := []tea.ProgramOption{
tea.WithFPS(tui.FrameRate),
}
Expand Down Expand Up @@ -801,6 +808,30 @@ func newModelClient(ctx context.Context, setting appconfig.ModelSetting, enableI
return c
}

// skillsInfoForTUI estimates the token footprint of the skill instructions
// available to agents (user + project skills directories, the same source
// executor.RegisterTools uses) so the TUI info bar can display it without
// re-reading skill files per frame. Count is the number of discovered
// skills; Tokens uses the common cl100k estimator over each skill's
// instruction body.
func skillsInfoForTUI() tui.SkillsInfo {
info := tui.SkillsInfo{}
skillDirs := []string{}
if userSkillsDir, err := pathutil.LateSkillsDir(); err == nil {
skillDirs = append(skillDirs, userSkillsDir)
}
skillDirs = append(skillDirs, pathutil.LateProjectSkillsDir())
skills, err := skill.DiscoverSkills(skillDirs)
if err != nil {
return info
}
for _, s := range skills {
info.Count++
info.Tokens += common.EstimateTokenCount(s.Instructions)
}
return info
}

func validateSuppressThinkingWords(suppressThinkingWords bool, orchestratorModel, subagentModel string, appConfig *appconfig.Config) error {
if !suppressThinkingWords {
return nil
Expand Down
8 changes: 8 additions & 0 deletions docs/quickstart.md
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,14 @@ When Late creates subagents, each appears in its own tab while it works and disa

---

## Info Bar & Timestamps

* `/infobar` toggles a single-line footer under the status bar: Late version, project folder, focused model, context usage, running subagents, discovered skills, tokens to the context threshold (80% of the model's context size), and session uptime.
* `/timestamps` toggles `[HH:MM:SS]` prefixes on transcript message blocks.
* Both toggles persist to `config.json` (`show-info-bar`, `show-timestamps`).

---

## Tool Approval

Potentially destructive commands and file changes require approval unless you have already granted permission for that scope.
Expand Down
4 changes: 4 additions & 0 deletions internal/client/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,10 @@ type ChatMessage struct {
ToolCalls []ToolCall `json:"tool_calls,omitempty"`
ToolCallID string `json:"tool_call_id,omitempty"` // For tool responses
AttachedFiles []string `json:"-"` // Purely for UI display
// Timestamp records when the session appended the message to history
// (RFC3339). Optional: legacy history entries predate the field and
// render without a time prefix in the transcript.
Timestamp string `json:"timestamp,omitempty"`
}

type MessageContent struct {
Expand Down
12 changes: 12 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,18 @@ type Config struct {
Theme string `json:"theme,omitempty"`
Models []ModelSetting `json:"models,omitempty"`
AgentModels map[string]string `json:"agent_models,omitempty"`

// ShowInfoBar toggles the single-line info footer rendered below the
// TUI status bar (version, model, context usage, uptime, ...). Toggled
// at runtime with the /infobar slash command, which persists the new
// value back to config.json.
ShowInfoBar bool `json:"show-info-bar,omitempty"`

// ShowTimestamps toggles the [HH:MM:SS] prefix rendered at the start
// of each transcript message block. Toggled at runtime with the
// /timestamps slash command, which persists the new value back to
// config.json.
ShowTimestamps bool `json:"show-timestamps,omitempty"`
}

func defaultConfig() Config {
Expand Down
32 changes: 21 additions & 11 deletions internal/session/session.go
Original file line number Diff line number Diff line change
Expand Up @@ -104,25 +104,36 @@ func (s *Session) ExecuteTool(ctx context.Context, tc client.ToolCall) (string,
return t.Execute(ctx, json.RawMessage(tc.Function.Arguments))
}

// appendMessage is the single path every Add* helper uses to record a
// message: it stamps the message's receive time (RFC3339) unless the caller
// supplied one, appends to history, and persists. The timestamp drives the
// optional [HH:MM:SS] prefix in the TUI transcript; legacy history entries
// that predate the field keep an empty Timestamp.
func (s *Session) appendMessage(msg client.ChatMessage) error {
if msg.Timestamp == "" {
msg.Timestamp = time.Now().Format(time.RFC3339)
}
s.History = append(s.History, msg)
return s.saveAndNotify()
}

// AddToolResultMessage adds a tool response message to history.
func (s *Session) AddToolResultMessage(toolCallID, content string) error {
s.History = append(s.History, client.ChatMessage{
return s.appendMessage(client.ChatMessage{
Role: "tool",
ToolCallID: toolCallID,
Content: client.TextContent(content),
})
return s.saveAndNotify()
}

// AddAssistantMessageWithTools adds an assistant message with tool calls.
func (s *Session) AddAssistantMessageWithTools(content string, reasoning string, toolCalls []client.ToolCall) error {
s.History = append(s.History, client.ChatMessage{
return s.appendMessage(client.ChatMessage{
Role: "assistant",
Content: client.TextContent(content),
ReasoningContent: reasoning,
ToolCalls: toolCalls,
})
return s.saveAndNotify()
}

func (s *Session) GetToolDefinitions() []client.ToolDefinition {
Expand All @@ -143,24 +154,23 @@ func (s *Session) GetToolDefinitions() []client.ToolDefinition {

// AddUserMessage adds a user message to history and persists it.
func (s *Session) AddUserMessage(content string) error {
s.History = append(s.History, client.ChatMessage{Role: "user", Content: client.TextContent(content)})
return s.saveAndNotify()
return s.appendMessage(client.ChatMessage{Role: "user", Content: client.TextContent(content)})
}

// AddMessage adds an arbitrary message to history and persists it.
// AddMessage adds an arbitrary message to history and persists it. A caller
// supplied Timestamp is preserved; otherwise the message is stamped like any
// other add.
func (s *Session) AddMessage(msg client.ChatMessage) error {
s.History = append(s.History, msg)
return s.saveAndNotify()
return s.appendMessage(msg)
}

// AddAssistantMessage adds an assistant message to history and persists it.
func (s *Session) AddAssistantMessage(content, reasoning string) error {
s.History = append(s.History, client.ChatMessage{
return s.appendMessage(client.ChatMessage{
Role: "assistant",
Content: client.TextContent(content),
ReasoningContent: reasoning,
})
return s.saveAndNotify()
}

// PopLastUserMessage removes the trailing user message from history and
Expand Down
49 changes: 49 additions & 0 deletions internal/session/timestamp_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
package session

import (
"testing"
"time"

"late/internal/client"
)

// Add* helpers stamp the message receive time (RFC3339) in one shared path
// (appendMessage) so the TUI transcript can prefix blocks with [HH:MM:SS].
func TestAddUserMessageStampsRFC3339Timestamp(t *testing.T) {
s := New(nil, "", nil, "", false) // no history path: nothing is persisted
if err := s.AddUserMessage("Hello"); err != nil {
t.Fatalf("AddUserMessage() error = %v", err)
}
ts := s.History[0].Timestamp
if ts == "" {
t.Fatal("expected AddUserMessage to stamp a non-empty Timestamp")
}
parsed, err := time.Parse(time.RFC3339, ts)
if err != nil {
t.Fatalf("Timestamp %q is not RFC3339: %v", ts, err)
}
if elapsed := time.Since(parsed); elapsed < 0 || elapsed > time.Minute {
t.Fatalf("Timestamp %q is not a recent receive time (elapsed %v)", ts, elapsed)
}
}

func TestAddMessageStampsEmptyAndPreservesCallerTimestamp(t *testing.T) {
s := New(nil, "", nil, "", false)

// An unstamped message added via AddMessage is stamped like any other.
if err := s.AddMessage(client.ChatMessage{Role: "tool", Content: client.TextContent("stamped")}); err != nil {
t.Fatalf("AddMessage() error = %v", err)
}
if s.History[0].Timestamp == "" {
t.Fatal("expected AddMessage to stamp an empty Timestamp")
}

// A caller supplied timestamp survives the round trip.
fixed := "2024-05-06T07:08:09Z"
if err := s.AddMessage(client.ChatMessage{Role: "assistant", Content: client.TextContent("preserved"), Timestamp: fixed}); err != nil {
t.Fatalf("AddMessage() error = %v", err)
}
if got := s.History[1].Timestamp; got != fixed {
t.Fatalf("AddMessage overwrote caller timestamp: got %q, want %q", got, fixed)
}
}
Loading
Loading